From eb57570607ad9738776e6653c1c89a03c8522947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 2 Oct 2025 23:53:21 +0900 Subject: [PATCH 01/35] =?UTF-8?q?Refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=ED=95=98=ED=8A=B8=EB=B9=84=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 49 +++--------------------------- 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 313809c..83f06ac 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -16,6 +16,8 @@ import * as Y from 'yjs'; origin: '*', }, namespace: '/cardsets', + pingTimeout: 60000, // 60초 + pingInterval: 25000, // 25초 }) export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect @@ -24,17 +26,13 @@ export class CollaborationGateway server: Server; private readonly logger = new Logger(CollaborationGateway.name); - private readonly heartbeatInterval = 10000; // 10초 - private heartbeatTimers = new Map(); private documentMap = new Map(); // documentId -> Y.Doc constructor() {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); - - // 10초마다 헬스체크 시작 - this.startHeartbeat(client); + // Socket.IO의 내장 PING/PONG 하트비트가 자동으로 처리됩니다 } handleDisconnect(client: Socket) { @@ -42,8 +40,7 @@ export class CollaborationGateway this.logger.log( `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, ); - - this.stopHeartbeat(client.id); + // Socket.IO의 내장 하트비트가 자동으로 연결 상태를 관리합니다 } @SubscribeMessage('joinRoom') @@ -330,42 +327,4 @@ export class CollaborationGateway }); } } - - @SubscribeMessage('heartbeat') - handleHeartbeat(@ConnectedSocket() client: Socket) { - // 클라이언트로부터 heartbeat 응답 받음 - this.logger.log(`Received heartbeat from client ${client.id}`); - client.emit('heartbeat-ack', { - clientId: client.id, - timestamp: new Date().toISOString(), - }); - } - - private startHeartbeat(client: Socket) { - const timer = setInterval(() => { - // 클라이언트가 연결되어 있는지 확인 - if (!client.connected) { - this.logger.warn( - `Client ${client.id} is not connected, stopping heartbeat`, - ); - this.stopHeartbeat(client.id); - return; - } - - // 클라이언트에게 heartbeat 전송 - client.emit('heartbeat', { - timestamp: new Date().toISOString(), - }); - }, this.heartbeatInterval); - - this.heartbeatTimers.set(client.id, timer); - } - - private stopHeartbeat(clientId: string) { - const timer = this.heartbeatTimers.get(clientId); - if (timer) { - clearInterval(timer); - this.heartbeatTimers.delete(clientId); - } - } } From a330a1f855849d85b77aefb25b7ff9cb2bfa63ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 3 Oct 2025 14:19:04 +0900 Subject: [PATCH 02/35] =?UTF-8?q?Refactor:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=8B=9C=20sync=EB=A1=9C=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 10 ---------- src/websocket/websocket.gateway.ts | 4 ++-- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index c06a38c..74c2947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.6", - "@types/socket.io": "^3.0.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", @@ -3064,15 +3063,6 @@ "@types/send": "*" } }, - "node_modules/@types/socket.io": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz", - "integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==", - "license": "MIT", - "dependencies": { - "socket.io": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 83f06ac..af32b38 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -256,9 +256,9 @@ export class CollaborationGateway const { update } = data as { update: number[] }; Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); - // 다른 클라이언트들에게 업데이트 브로드캐스트 + // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 client.to(documentId).emit('yjs-message', { - type: 'update', + type: 'sync', data: { update: Array.from(update) }, }); } From 53de34b420744fc852293b4c9129d205e89193a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 3 Oct 2025 14:40:33 +0900 Subject: [PATCH 03/35] =?UTF-8?q?Refactor:=20yjs-message=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 138 ++++++++++++++++++----------- 1 file changed, 87 insertions(+), 51 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index af32b38..a22c7d4 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -65,7 +65,7 @@ export class CollaborationGateway // 클라이언트에게 현재 문서 상태 전송 const state = Y.encodeStateAsUpdate(doc); - client.emit('yjs-update', { + client.emit('sync', { documentId: data.documentId, update: Array.from(state), }); @@ -151,68 +151,105 @@ export class CollaborationGateway } } - @SubscribeMessage('yjs-message') - handleYjsMessage( + @SubscribeMessage('sync') + handleSync( @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId?: string; type: string; data: any }, + @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, ) { try { this.logger.log( - `Received yjs-message from client ${client.id}: ${JSON.stringify(data)}`, + `Received sync from client ${client.id} for document ${data.documentId}`, ); - const { - documentId, - type, - data: messageData, - } = data as { documentId?: string; type: string; data: unknown }; - - // auth 메시지 처리 - if (type === 'auth') { - this.handleAuth( - client, - messageData as { token: string; userId: string; documentId: string }, + const doc = this.documentMap.get(data.documentId); + + if (!doc) { + this.logger.warn( + `Document not found: ${data.documentId} for client ${client.id}`, ); + client.emit('error', { message: 'Document not found' }); return; } - // documentId가 없으면 에러 - if (!documentId) { - this.logger.warn(`Document ID required for client ${client.id}`); - client.emit('error', { message: 'Document ID required' }); + this.handleSyncMessage(client, doc, data.documentId, data); + } catch (error) { + this.logger.error( + `Error processing sync from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + @SubscribeMessage('update') + handleUpdate( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; update: number[] }, + ) { + try { + this.logger.log( + `Received update from client ${client.id} for document ${data.documentId}`, + ); + + const doc = this.documentMap.get(data.documentId); + + if (!doc) { + this.logger.warn( + `Document not found: ${data.documentId} for client ${client.id}`, + ); + client.emit('error', { message: 'Document not found' }); return; } - const doc = this.documentMap.get(documentId); + this.handleUpdateMessage(client, doc, data.documentId, data); + } catch (error) { + this.logger.error( + `Error processing update from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + @SubscribeMessage('awareness') + handleAwareness( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; awareness: number[] }, + ) { + try { + this.logger.log( + `Received awareness from client ${client.id} for document ${data.documentId}`, + ); + + const doc = this.documentMap.get(data.documentId); if (!doc) { this.logger.warn( - `Document not found: ${documentId} for client ${client.id}`, + `Document not found: ${data.documentId} for client ${client.id}`, ); client.emit('error', { message: 'Document not found' }); return; } - switch (type) { - case 'sync': - this.handleSyncMessage(client, doc, documentId, messageData); - break; - case 'update': - this.handleUpdateMessage(client, doc, documentId, messageData); - break; - case 'awareness': - this.handleAwarenessMessage(client, doc, documentId, messageData); - break; - default: - this.logger.warn( - `Unknown message type: ${type} from client ${client.id}`, - ); - } + this.handleAwarenessMessage(client, doc, data.documentId, data); + } catch (error) { + this.logger.error( + `Error processing awareness from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + @SubscribeMessage('auth') + handleAuthMessage( + @ConnectedSocket() client: Socket, + @MessageBody() data: { token: string; userId: string; documentId: string }, + ) { + try { + this.logger.log( + `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, + ); + + this.handleAuth(client, data); } catch (error) { this.logger.error( - `Error processing YJS message from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Error processing auth from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, ); - this.logger.error(`Raw data: ${JSON.stringify(data)}`); } } @@ -230,9 +267,9 @@ export class CollaborationGateway if (syncStep === 0) { // Step 0: 클라이언트가 현재 상태 벡터 전송 const stateVector = Y.encodeStateVector(doc); - client.emit('yjs-message', { - type: 'sync', - data: { syncStep: 1, update: Array.from(stateVector) }, + client.emit('sync', { + syncStep: 1, + update: Array.from(stateVector), }); } else if (syncStep === 1 && update) { // Step 1: 서버가 차이점 전송 @@ -240,9 +277,9 @@ export class CollaborationGateway doc, new Uint8Array(update as unknown as ArrayBufferLike), ); - client.emit('yjs-message', { - type: 'sync', - data: { syncStep: 1, update: Array.from(diff) }, + client.emit('sync', { + syncStep: 1, + update: Array.from(diff), }); } } @@ -257,9 +294,9 @@ export class CollaborationGateway Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 - client.to(documentId).emit('yjs-message', { - type: 'sync', - data: { update: Array.from(update) }, + client.to(documentId).emit('sync', { + syncStep: 1, + update: Array.from(update), }); } @@ -272,9 +309,8 @@ export class CollaborationGateway const { awareness } = data as { awareness: number[] }; // 다른 클라이언트들에게 awareness 브로드캐스트 - client.to(documentId).emit('yjs-message', { - type: 'awareness', - data: { awareness: Array.from(awareness) }, + client.to(documentId).emit('awareness', { + awareness: Array.from(awareness), }); } From bfde803f253cc28ec81d71106aa262a900b94edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 3 Oct 2025 15:40:50 +0900 Subject: [PATCH 04/35] =?UTF-8?q?Refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=9B=84=20=EC=97=B0=EA=B2=B0=EC=8B=9C=20=EB=B0=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 173 ++++++++--------------------- 1 file changed, 48 insertions(+), 125 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index a22c7d4..40d2124 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -40,17 +40,16 @@ export class CollaborationGateway this.logger.log( `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, ); - // Socket.IO의 내장 하트비트가 자동으로 연결 상태를 관리합니다 } - @SubscribeMessage('joinRoom') - handleJoinDocument( + @SubscribeMessage('sync') + handleSync( @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; userId?: string }, + @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, ) { try { this.logger.log( - `Client ${client.id} joining document: ${data.documentId}`, + `Received sync from client ${client.id} for document ${data.documentId}`, ); // 클라이언트를 룸에 조인 @@ -61,114 +60,7 @@ export class CollaborationGateway if (!doc) { doc = new Y.Doc(); this.documentMap.set(data.documentId, doc); - } - - // 클라이언트에게 현재 문서 상태 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync', { - documentId: data.documentId, - update: Array.from(state), - }); - - // 조인 성공 응답 - client.emit('joinRoom', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), - }); - - // 다른 클라이언트들에게 새 클라이언트 알림 - client.to(data.documentId).emit('user-joined', { - clientId: client.id, - userId: data.userId, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to join document' }); - } - } - - @SubscribeMessage('leaveRoom') - handleLeaveDocument( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string }, - ) { - try { - this.logger.log( - `Client ${client.id} leaving document: ${data.documentId}`, - ); - - // YJS 서비스 제거로 인한 간단한 처리 - - // 룸에서 나가기 - void client.leave(data.documentId); - - // 나가기 성공 응답 - client.emit('userLeft', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), - }); - - // 다른 클라이언트들에게 클라이언트 나감 알림 - client.to(data.documentId).emit('userLeft', { - clientId: client.id, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to leave document' }); - } - } - - @SubscribeMessage('sendMessage') - handleTextUpdate( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; field: string; content: string }, - ) { - try { - this.logger.log( - `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, - ); - - // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 - client.to(data.documentId).emit('text-update', { - documentId: data.documentId, - field: data.field, - content: data.content, - fromClientId: client.id, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('sync') - handleSync( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, - ) { - try { - this.logger.log( - `Received sync from client ${client.id} for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); - - if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); - return; + this.logger.log(`Created new document: ${data.documentId}`); } this.handleSyncMessage(client, doc, data.documentId, data); @@ -189,14 +81,15 @@ export class CollaborationGateway `Received update from client ${client.id} for document ${data.documentId}`, ); - const doc = this.documentMap.get(data.documentId); + // 클라이언트를 룸에 조인 + void client.join(data.documentId); + // Yjs 문서 초기화 또는 가져오기 + let doc = this.documentMap.get(data.documentId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); - return; + doc = new Y.Doc(); + this.documentMap.set(data.documentId, doc); + this.logger.log(`Created new document: ${data.documentId}`); } this.handleUpdateMessage(client, doc, data.documentId, data); @@ -217,14 +110,15 @@ export class CollaborationGateway `Received awareness from client ${client.id} for document ${data.documentId}`, ); - const doc = this.documentMap.get(data.documentId); + // 클라이언트를 룸에 조인 + void client.join(data.documentId); + // Yjs 문서 초기화 또는 가져오기 + let doc = this.documentMap.get(data.documentId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); - return; + doc = new Y.Doc(); + this.documentMap.set(data.documentId, doc); + this.logger.log(`Created new document: ${data.documentId}`); } this.handleAwarenessMessage(client, doc, data.documentId, data); @@ -264,9 +158,16 @@ export class CollaborationGateway update?: number[]; }; + this.logger.log( + `[SYNC] Document: ${documentId}, Client: ${client.id}, Step: ${syncStep}, Update length: ${update?.length || 0}`, + ); + if (syncStep === 0) { // Step 0: 클라이언트가 현재 상태 벡터 전송 const stateVector = Y.encodeStateVector(doc); + this.logger.log( + `[SYNC] Sending state vector to client ${client.id}, length: ${stateVector.length}`, + ); client.emit('sync', { syncStep: 1, update: Array.from(stateVector), @@ -277,6 +178,9 @@ export class CollaborationGateway doc, new Uint8Array(update as unknown as ArrayBufferLike), ); + this.logger.log( + `[SYNC] Sending diff to client ${client.id}, diff length: ${diff.length}`, + ); client.emit('sync', { syncStep: 1, update: Array.from(diff), @@ -291,9 +195,19 @@ export class CollaborationGateway data: any, ) { const { update } = data as { update: number[] }; + + this.logger.log( + `[UPDATE] Document: ${documentId}, Client: ${client.id}, Update length: ${update.length}`, + ); + Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 + const roomClients = this.server.sockets.adapter.rooms.get(documentId); + this.logger.log( + `[UPDATE] Broadcasting to ${roomClients?.size || 0} clients in room ${documentId}`, + ); + client.to(documentId).emit('sync', { syncStep: 1, update: Array.from(update), @@ -308,7 +222,16 @@ export class CollaborationGateway ) { const { awareness } = data as { awareness: number[] }; + this.logger.log( + `[AWARENESS] Document: ${documentId}, Client: ${client.id}, Awareness length: ${awareness.length}`, + ); + // 다른 클라이언트들에게 awareness 브로드캐스트 + const roomClients = this.server.sockets.adapter.rooms.get(documentId); + this.logger.log( + `[AWARENESS] Broadcasting to ${roomClients?.size || 0} clients in room ${documentId}`, + ); + client.to(documentId).emit('awareness', { awareness: Array.from(awareness), }); From 19c2de6296cd6e9776222c93d115eb9e57ab84a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 3 Oct 2025 16:27:57 +0900 Subject: [PATCH 05/35] =?UTF-8?q?Fix:=20join=EB=A3=B8=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 173 +++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 48 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 40d2124..a22c7d4 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -40,16 +40,17 @@ export class CollaborationGateway this.logger.log( `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, ); + // Socket.IO의 내장 하트비트가 자동으로 연결 상태를 관리합니다 } - @SubscribeMessage('sync') - handleSync( + @SubscribeMessage('joinRoom') + handleJoinDocument( @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, + @MessageBody() data: { documentId: string; userId?: string }, ) { try { this.logger.log( - `Received sync from client ${client.id} for document ${data.documentId}`, + `Client ${client.id} joining document: ${data.documentId}`, ); // 클라이언트를 룸에 조인 @@ -60,7 +61,114 @@ export class CollaborationGateway if (!doc) { doc = new Y.Doc(); this.documentMap.set(data.documentId, doc); - this.logger.log(`Created new document: ${data.documentId}`); + } + + // 클라이언트에게 현재 문서 상태 전송 + const state = Y.encodeStateAsUpdate(doc); + client.emit('sync', { + documentId: data.documentId, + update: Array.from(state), + }); + + // 조인 성공 응답 + client.emit('joinRoom', { + documentId: data.documentId, + clientId: client.id, + timestamp: new Date().toISOString(), + }); + + // 다른 클라이언트들에게 새 클라이언트 알림 + client.to(data.documentId).emit('user-joined', { + clientId: client.id, + userId: data.userId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error( + `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + client.emit('error', { message: 'Failed to join document' }); + } + } + + @SubscribeMessage('leaveRoom') + handleLeaveDocument( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string }, + ) { + try { + this.logger.log( + `Client ${client.id} leaving document: ${data.documentId}`, + ); + + // YJS 서비스 제거로 인한 간단한 처리 + + // 룸에서 나가기 + void client.leave(data.documentId); + + // 나가기 성공 응답 + client.emit('userLeft', { + documentId: data.documentId, + clientId: client.id, + timestamp: new Date().toISOString(), + }); + + // 다른 클라이언트들에게 클라이언트 나감 알림 + client.to(data.documentId).emit('userLeft', { + clientId: client.id, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error( + `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + client.emit('error', { message: 'Failed to leave document' }); + } + } + + @SubscribeMessage('sendMessage') + handleTextUpdate( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; field: string; content: string }, + ) { + try { + this.logger.log( + `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, + ); + + // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 + client.to(data.documentId).emit('text-update', { + documentId: data.documentId, + field: data.field, + content: data.content, + fromClientId: client.id, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error( + `Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + @SubscribeMessage('sync') + handleSync( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, + ) { + try { + this.logger.log( + `Received sync from client ${client.id} for document ${data.documentId}`, + ); + + const doc = this.documentMap.get(data.documentId); + + if (!doc) { + this.logger.warn( + `Document not found: ${data.documentId} for client ${client.id}`, + ); + client.emit('error', { message: 'Document not found' }); + return; } this.handleSyncMessage(client, doc, data.documentId, data); @@ -81,15 +189,14 @@ export class CollaborationGateway `Received update from client ${client.id} for document ${data.documentId}`, ); - // 클라이언트를 룸에 조인 - void client.join(data.documentId); + const doc = this.documentMap.get(data.documentId); - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); if (!doc) { - doc = new Y.Doc(); - this.documentMap.set(data.documentId, doc); - this.logger.log(`Created new document: ${data.documentId}`); + this.logger.warn( + `Document not found: ${data.documentId} for client ${client.id}`, + ); + client.emit('error', { message: 'Document not found' }); + return; } this.handleUpdateMessage(client, doc, data.documentId, data); @@ -110,15 +217,14 @@ export class CollaborationGateway `Received awareness from client ${client.id} for document ${data.documentId}`, ); - // 클라이언트를 룸에 조인 - void client.join(data.documentId); + const doc = this.documentMap.get(data.documentId); - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); if (!doc) { - doc = new Y.Doc(); - this.documentMap.set(data.documentId, doc); - this.logger.log(`Created new document: ${data.documentId}`); + this.logger.warn( + `Document not found: ${data.documentId} for client ${client.id}`, + ); + client.emit('error', { message: 'Document not found' }); + return; } this.handleAwarenessMessage(client, doc, data.documentId, data); @@ -158,16 +264,9 @@ export class CollaborationGateway update?: number[]; }; - this.logger.log( - `[SYNC] Document: ${documentId}, Client: ${client.id}, Step: ${syncStep}, Update length: ${update?.length || 0}`, - ); - if (syncStep === 0) { // Step 0: 클라이언트가 현재 상태 벡터 전송 const stateVector = Y.encodeStateVector(doc); - this.logger.log( - `[SYNC] Sending state vector to client ${client.id}, length: ${stateVector.length}`, - ); client.emit('sync', { syncStep: 1, update: Array.from(stateVector), @@ -178,9 +277,6 @@ export class CollaborationGateway doc, new Uint8Array(update as unknown as ArrayBufferLike), ); - this.logger.log( - `[SYNC] Sending diff to client ${client.id}, diff length: ${diff.length}`, - ); client.emit('sync', { syncStep: 1, update: Array.from(diff), @@ -195,19 +291,9 @@ export class CollaborationGateway data: any, ) { const { update } = data as { update: number[] }; - - this.logger.log( - `[UPDATE] Document: ${documentId}, Client: ${client.id}, Update length: ${update.length}`, - ); - Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 - const roomClients = this.server.sockets.adapter.rooms.get(documentId); - this.logger.log( - `[UPDATE] Broadcasting to ${roomClients?.size || 0} clients in room ${documentId}`, - ); - client.to(documentId).emit('sync', { syncStep: 1, update: Array.from(update), @@ -222,16 +308,7 @@ export class CollaborationGateway ) { const { awareness } = data as { awareness: number[] }; - this.logger.log( - `[AWARENESS] Document: ${documentId}, Client: ${client.id}, Awareness length: ${awareness.length}`, - ); - // 다른 클라이언트들에게 awareness 브로드캐스트 - const roomClients = this.server.sockets.adapter.rooms.get(documentId); - this.logger.log( - `[AWARENESS] Broadcasting to ${roomClients?.size || 0} clients in room ${documentId}`, - ); - client.to(documentId).emit('awareness', { awareness: Array.from(awareness), }); From ac81f70f0fdd6717a032907ce4df9cdcbac581d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 22 Oct 2025 23:41:34 +0900 Subject: [PATCH 06/35] =?UTF-8?q?Refactor:=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=EC=8B=9C=20auth=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.module.ts | 5 ++-- src/auth/ws-auth.guard.ts | 45 +++++++++++++++++++++++++++++ src/decorators/ws-user.decorator.ts | 9 ++++++ src/websocket/websocket.gateway.ts | 19 ++++++++---- 4 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 src/auth/ws-auth.guard.ts create mode 100644 src/decorators/ws-user.decorator.ts diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 0ddb2b5..7c6df0f 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { WsAuthGuard } from './ws-auth.guard'; import authConfig from '../config/authConfig'; import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ConfigModule.forFeature(authConfig)], controllers: [AuthController], - providers: [AuthService], - exports: [], + providers: [AuthService, WsAuthGuard], + exports: [AuthService, WsAuthGuard], }) export class AuthModule {} diff --git a/src/auth/ws-auth.guard.ts b/src/auth/ws-auth.guard.ts new file mode 100644 index 0000000..127d591 --- /dev/null +++ b/src/auth/ws-auth.guard.ts @@ -0,0 +1,45 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { Socket } from 'socket.io'; + +@Injectable() +export class WsAuthGuard implements CanActivate { + private readonly logger = new Logger(WsAuthGuard.name); + + constructor(private readonly authService: AuthService) {} + + canActivate(context: ExecutionContext): boolean { + const client: Socket = context.switchToWs().getClient(); + + // 1) socket.io v4: client.handshake.auth.token 로 받기 + // 2) 또는 Authorization 헤더로 받기 + const bearer = + (client.handshake.auth?.token as string | undefined) ?? + client.handshake.headers?.authorization; + + const token = + bearer && bearer.startsWith('Bearer ') ? bearer.slice(7) : bearer; + + if (!token) { + this.logger.warn(`No token provided for client ${client.id}`); + return false; + } + + try { + const user = this.authService.verify(token); + // 이후 핸들러에서 사용할 수 있도록 저장 + (client.data as { user: unknown }).user = user; + return true; + } catch (error) { + this.logger.warn( + `Invalid token for client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + return false; + } + } +} diff --git a/src/decorators/ws-user.decorator.ts b/src/decorators/ws-user.decorator.ts new file mode 100644 index 0000000..020b9f3 --- /dev/null +++ b/src/decorators/ws-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { UserAuth } from '../types/userAuth.type'; + +export const WsUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext): UserAuth => { + const client = ctx.switchToWs().getClient<{ data: { user?: UserAuth } }>(); + return client.data?.user as UserAuth; + }, +); diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index a22c7d4..f791b30 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -8,9 +8,12 @@ import { ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { Logger } from '@nestjs/common'; +import { Logger, UseGuards } from '@nestjs/common'; import * as Y from 'yjs'; - +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { WsUser } from '../decorators/ws-user.decorator'; +import { UserAuth } from '../types/userAuth.type'; +@UseGuards(WsAuthGuard) // 인증 가드 적용 @WebSocketGateway({ cors: { origin: '*', @@ -153,12 +156,14 @@ export class CollaborationGateway @SubscribeMessage('sync') handleSync( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; syncStep: number; update?: number[] }, + @MessageBody() + data: { documentId: string; syncStep: number; update?: number[] }, ) { try { this.logger.log( - `Received sync from client ${client.id} for document ${data.documentId}`, + `Received sync from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, ); const doc = this.documentMap.get(data.documentId); @@ -181,12 +186,13 @@ export class CollaborationGateway @SubscribeMessage('update') handleUpdate( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, @MessageBody() data: { documentId: string; update: number[] }, ) { try { this.logger.log( - `Received update from client ${client.id} for document ${data.documentId}`, + `Received update from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, ); const doc = this.documentMap.get(data.documentId); @@ -209,12 +215,13 @@ export class CollaborationGateway @SubscribeMessage('awareness') handleAwareness( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, @MessageBody() data: { documentId: string; awareness: number[] }, ) { try { this.logger.log( - `Received awareness from client ${client.id} for document ${data.documentId}`, + `Received awareness from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, ); const doc = this.documentMap.get(data.documentId); From e0fe15f0126caee55de8f715fbd178bfede73643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 31 Oct 2025 18:09:32 +0900 Subject: [PATCH 07/35] =?UTF-8?q?Refactor:=20=EB=A9=94=EC=8B=9C=EC=A7=95?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 374 +++++++---------------------- 1 file changed, 90 insertions(+), 284 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index f791b30..f79184d 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -2,17 +2,18 @@ import { WebSocketGateway, WebSocketServer, SubscribeMessage, + ConnectedSocket, + MessageBody, OnGatewayConnection, OnGatewayDisconnect, - MessageBody, - ConnectedSocket, } from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; import { Logger, UseGuards } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; import * as Y from 'yjs'; import { WsAuthGuard } from '../auth/ws-auth.guard'; import { WsUser } from '../decorators/ws-user.decorator'; -import { UserAuth } from '../types/userAuth.type'; +import type { UserAuth } from '../types/userAuth.type'; + @UseGuards(WsAuthGuard) // 인증 가드 적용 @WebSocketGateway({ cors: { @@ -22,352 +23,157 @@ import { UserAuth } from '../types/userAuth.type'; pingTimeout: 60000, // 60초 pingInterval: 25000, // 25초 }) -export class CollaborationGateway - implements OnGatewayConnection, OnGatewayDisconnect -{ +export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly logger = new Logger(CollaborationGateway.name); - private documentMap = new Map(); // documentId -> Y.Doc + private documentMap = new Map(); // cardsetId -> Y.Doc constructor() {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); - // Socket.IO의 내장 PING/PONG 하트비트가 자동으로 처리됩니다 } handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - this.logger.log( - `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, - ); - // Socket.IO의 내장 하트비트가 자동으로 연결 상태를 관리합니다 + // 클라이언트가 연결된 모든 카드셋에서 나가기 + for (const [cardsetId, doc] of this.documentMap) { + void client.leave(`cardset:${cardsetId}`); + } } - @SubscribeMessage('joinRoom') - handleJoinDocument( + // 카드셋에 조인 (카드셋의 Yjs 문서에 접근) + @SubscribeMessage('join-cardset') + async handleJoinCardset( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; userId?: string }, + @MessageBody() data: { cardsetId: string }, ) { try { - this.logger.log( - `Client ${client.id} joining document: ${data.documentId}`, - ); + const { cardsetId } = data; + this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); - // 클라이언트를 룸에 조인 - void client.join(data.documentId); + // 카드셋 룸에 조인 + void client.join(`cardset:${cardsetId}`); - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); + // 카드셋의 Yjs 문서 가져오기 또는 생성 + let doc = this.documentMap.get(cardsetId); if (!doc) { doc = new Y.Doc(); - this.documentMap.set(data.documentId, doc); + this.documentMap.set(cardsetId, doc); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); } - // 클라이언트에게 현재 문서 상태 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync', { - documentId: data.documentId, - update: Array.from(state), + // 클라이언트에게 현재 카드셋 상태 전송 + const cards = doc.getArray('cards'); + client.emit('cardset-state', { + cardsetId, + cards: cards.toArray(), }); - // 조인 성공 응답 - client.emit('joinRoom', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), - }); - - // 다른 클라이언트들에게 새 클라이언트 알림 - client.to(data.documentId).emit('user-joined', { - clientId: client.id, - userId: data.userId, - timestamp: new Date().toISOString(), - }); + this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to join document' }); + this.logger.error('Error joining cardset:', error); + client.emit('error', { message: 'Failed to join cardset' }); } } - @SubscribeMessage('leaveRoom') - handleLeaveDocument( + // 카드셋에서 나가기 + @SubscribeMessage('leave-cardset') + async handleLeaveCardset( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string }, + @MessageBody() data: { cardsetId: string }, ) { try { - this.logger.log( - `Client ${client.id} leaving document: ${data.documentId}`, - ); - - // YJS 서비스 제거로 인한 간단한 처리 + const { cardsetId } = data; + this.logger.log(`User ${user.userId} leaving cardset ${cardsetId}`); - // 룸에서 나가기 - void client.leave(data.documentId); - - // 나가기 성공 응답 - client.emit('userLeft', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), - }); - - // 다른 클라이언트들에게 클라이언트 나감 알림 - client.to(data.documentId).emit('userLeft', { - clientId: client.id, - timestamp: new Date().toISOString(), - }); + void client.leave(`cardset:${cardsetId}`); + this.logger.log(`User ${user.userId} left cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to leave document' }); + this.logger.error('Error leaving cardset:', error); } } - @SubscribeMessage('sendMessage') - handleTextUpdate( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; field: string; content: string }, - ) { - try { - this.logger.log( - `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, - ); - - // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 - client.to(data.documentId).emit('text-update', { - documentId: data.documentId, - field: data.field, - content: data.content, - fromClientId: client.id, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - @SubscribeMessage('sync') - handleSync( + // 카드 업데이트 + @SubscribeMessage('update-card') + async handleUpdateCard( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() - data: { documentId: string; syncStep: number; update?: number[] }, + @MessageBody() data: { cardsetId: string; cardId: string; updates: Partial<{ content: string; order: number }> }, ) { try { - this.logger.log( - `Received sync from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); + const { cardsetId, cardId, updates } = data; + this.logger.log(`User ${user.userId} updating card ${cardId} in cardset ${cardsetId}`); + const doc = this.documentMap.get(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + client.emit('error', { message: 'Cardset not found' }); return; } - this.handleSyncMessage(client, doc, data.documentId, data); - } catch (error) { - this.logger.error( - `Error processing sync from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('update') - handleUpdate( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; update: number[] }, - ) { - try { - this.logger.log( - `Received update from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); - - if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + const cards = doc.getArray('cards'); + const cardsArray = cards.toArray(); + const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); + + if (cardIndex === -1) { + client.emit('error', { message: 'Card not found' }); return; } - this.handleUpdateMessage(client, doc, data.documentId, data); + const currentCard = cardsArray[cardIndex] as any; + const updatedCard = { + ...currentCard, + ...updates, + updatedAt: new Date().toISOString(), + }; + + cards.delete(cardIndex, 1); + cards.insert(cardIndex, [updatedCard]); + + this.logger.log(`Card ${cardId} updated in cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error processing update from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + this.logger.error('Error updating card:', error); + client.emit('error', { message: 'Failed to update card' }); } } - @SubscribeMessage('awareness') - handleAwareness( + + // Yjs 동기화 (클라이언트가 변경사항을 받을 때) + @SubscribeMessage('sync') + async handleSync( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; awareness: number[] }, + @MessageBody() data: { cardsetId: string; syncStep: number; update?: number[] }, ) { try { - this.logger.log( - `Received awareness from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); + const { cardsetId, syncStep, update } = data; + this.logger.log(`Sync request from user ${user.userId} for cardset ${cardsetId}`); + const doc = this.documentMap.get(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + client.emit('error', { message: 'Cardset not found' }); return; } - this.handleAwarenessMessage(client, doc, data.documentId, data); - } catch (error) { - this.logger.error( - `Error processing awareness from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('auth') - handleAuthMessage( - @ConnectedSocket() client: Socket, - @MessageBody() data: { token: string; userId: string; documentId: string }, - ) { - try { - this.logger.log( - `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, - ); - - this.handleAuth(client, data); - } catch (error) { - this.logger.error( - `Error processing auth from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - private handleSyncMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { syncStep, update } = data as { - syncStep: number; - update?: number[]; - }; + if (update) { + // 클라이언트에서 온 업데이트 적용 + Y.applyUpdate(doc, new Uint8Array(update)); + } - if (syncStep === 0) { - // Step 0: 클라이언트가 현재 상태 벡터 전송 - const stateVector = Y.encodeStateVector(doc); - client.emit('sync', { - syncStep: 1, - update: Array.from(stateVector), - }); - } else if (syncStep === 1 && update) { - // Step 1: 서버가 차이점 전송 - const diff = Y.encodeStateAsUpdate( - doc, - new Uint8Array(update as unknown as ArrayBufferLike), - ); - client.emit('sync', { - syncStep: 1, - update: Array.from(diff), + // 현재 상태를 클라이언트에게 전송 + const state = Y.encodeStateAsUpdate(doc); + client.emit('sync-response', { + cardsetId, + update: Array.from(state), }); - } - } - - private handleUpdateMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { update } = data as { update: number[] }; - Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); - - // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 - client.to(documentId).emit('sync', { - syncStep: 1, - update: Array.from(update), - }); - } - - private handleAwarenessMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { awareness } = data as { awareness: number[] }; - - // 다른 클라이언트들에게 awareness 브로드캐스트 - client.to(documentId).emit('awareness', { - awareness: Array.from(awareness), - }); - } - - private handleAuth( - client: Socket, - data: { token: string; userId: string; documentId: string }, - ) { - try { - this.logger.log( - `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, - ); - this.logger.log(`Auth data: ${JSON.stringify(data)}`); - - // TODO: 실제 JWT 토큰 검증 로직 구현 - // 임시로 토큰이 있으면 인증 성공으로 처리 - const hasAccess = true; - - const accessControlMessage = { - data: { - hasAccess, - message: hasAccess - ? 'Authentication successful' - : 'Authentication failed', - }, - clientId: client.id, - timestamp: new Date().toISOString(), - }; - - this.logger.log( - `Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`, - ); - client.emit('access-control', accessControlMessage); - - // 클라이언트가 이벤트를 받을 시간을 주기 위해 약간의 지연 - setTimeout(() => { - this.logger.log(`Auth result for client ${client.id}: ${hasAccess}`); - }, 100); } catch (error) { - this.logger.error( - `Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - this.logger.error(`Auth data: ${JSON.stringify(data)}`); - client.emit('access-control', { - data: { - hasAccess: false, - message: 'Authentication error', - }, - clientId: client.id, - timestamp: new Date().toISOString(), - }); + this.logger.error('Error during sync:', error); + client.emit('error', { message: 'Sync failed' }); } } -} +} \ No newline at end of file From 25d8b073e2bb7f4e3b1e72756ebdbcf1092ebe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 31 Oct 2025 18:23:36 +0900 Subject: [PATCH 08/35] =?UTF-8?q?Refactor:=20=EC=84=9C=EB=B2=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20sync=EB=A1=9C=20=EB=B8=8C=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index f79184d..38b28ea 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -167,7 +167,7 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco // 현재 상태를 클라이언트에게 전송 const state = Y.encodeStateAsUpdate(doc); - client.emit('sync-response', { + client.emit('sync', { cardsetId, update: Array.from(state), }); From a156a77375c5515ac1ee69bb640450fa23bedc29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 31 Oct 2025 18:28:33 +0900 Subject: [PATCH 09/35] =?UTF-8?q?Refactor:=20=EB=B8=8C=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8A=B8=20=ED=98=95=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 38b28ea..60dc77e 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -100,7 +100,7 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco // 카드 업데이트 - @SubscribeMessage('update-card') + @SubscribeMessage('update') async handleUpdateCard( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, @@ -135,7 +135,14 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco cards.delete(cardIndex, 1); cards.insert(cardIndex, [updatedCard]); - this.logger.log(`Card ${cardId} updated in cardset ${cardsetId}`); + // 업데이트 후 모든 클라이언트에게 sync 브로드캐스트 + const state = Y.encodeStateAsUpdate(doc); + this.server.to(`cardset:${cardsetId}`).emit('sync', { + cardsetId, + update: Array.from(state), + }); + + this.logger.log(`Card ${cardId} updated in cardset ${cardsetId} and sync broadcasted`); } catch (error) { this.logger.error('Error updating card:', error); client.emit('error', { message: 'Failed to update card' }); @@ -163,14 +170,22 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco if (update) { // 클라이언트에서 온 업데이트 적용 Y.applyUpdate(doc, new Uint8Array(update)); + + // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 + const state = Y.encodeStateAsUpdate(doc); + this.server.to(`cardset:${cardsetId}`).emit('sync', { + cardsetId, + update: Array.from(state), + }); + this.logger.log(`Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`); + } else { + // 업데이트가 없으면 현재 상태를 요청한 클라이언트에게만 전송 + const state = Y.encodeStateAsUpdate(doc); + client.emit('sync', { + cardsetId, + update: Array.from(state), + }); } - - // 현재 상태를 클라이언트에게 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync', { - cardsetId, - update: Array.from(state), - }); } catch (error) { this.logger.error('Error during sync:', error); client.emit('error', { message: 'Sync failed' }); From ae4c18fa6100731898f867eaae43502355dc3ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 6 Nov 2025 11:54:37 +0900 Subject: [PATCH 10/35] =?UTF-8?q?Refactor:=20=EC=B9=B4=EB=93=9C=EC=85=8B?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=98=95=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 83 +++++++----------------------- 1 file changed, 19 insertions(+), 64 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 60dc77e..80340c2 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -23,7 +23,9 @@ import type { UserAuth } from '../types/userAuth.type'; pingTimeout: 60000, // 60초 pingInterval: 25000, // 25초 }) -export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect { +export class CollaborationGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ @WebSocketServer() server: Server; @@ -39,7 +41,7 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); // 클라이언트가 연결된 모든 카드셋에서 나가기 - for (const [cardsetId, doc] of this.documentMap) { + for (const [cardsetId] of this.documentMap) { void client.leave(`cardset:${cardsetId}`); } } @@ -67,10 +69,11 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco } // 클라이언트에게 현재 카드셋 상태 전송 - const cards = doc.getArray('cards'); - client.emit('cardset-state', { + const state = Y.encodeStateAsUpdate(doc); + + client.emit('sync', { cardsetId, - cards: cards.toArray(), + update: Array.from(state), }); this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); @@ -98,68 +101,18 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco } } - - // 카드 업데이트 + // Yjs 업데이트 (클라이언트가 변경사항을 받을 때) @SubscribeMessage('update') - async handleUpdateCard( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string; cardId: string; updates: Partial<{ content: string; order: number }> }, - ) { - try { - const { cardsetId, cardId, updates } = data; - this.logger.log(`User ${user.userId} updating card ${cardId} in cardset ${cardsetId}`); - - const doc = this.documentMap.get(cardsetId); - if (!doc) { - client.emit('error', { message: 'Cardset not found' }); - return; - } - - const cards = doc.getArray('cards'); - const cardsArray = cards.toArray(); - const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); - - if (cardIndex === -1) { - client.emit('error', { message: 'Card not found' }); - return; - } - - const currentCard = cardsArray[cardIndex] as any; - const updatedCard = { - ...currentCard, - ...updates, - updatedAt: new Date().toISOString(), - }; - - cards.delete(cardIndex, 1); - cards.insert(cardIndex, [updatedCard]); - - // 업데이트 후 모든 클라이언트에게 sync 브로드캐스트 - const state = Y.encodeStateAsUpdate(doc); - this.server.to(`cardset:${cardsetId}`).emit('sync', { - cardsetId, - update: Array.from(state), - }); - - this.logger.log(`Card ${cardId} updated in cardset ${cardsetId} and sync broadcasted`); - } catch (error) { - this.logger.error('Error updating card:', error); - client.emit('error', { message: 'Failed to update card' }); - } - } - - - // Yjs 동기화 (클라이언트가 변경사항을 받을 때) - @SubscribeMessage('sync') async handleSync( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string; syncStep: number; update?: number[] }, + @MessageBody() data: { cardsetId: string; update?: number[] }, ) { try { - const { cardsetId, syncStep, update } = data; - this.logger.log(`Sync request from user ${user.userId} for cardset ${cardsetId}`); + const { cardsetId, update } = data; + this.logger.log( + `Sync request from user ${user.userId} for cardset ${cardsetId}`, + ); const doc = this.documentMap.get(cardsetId); if (!doc) { @@ -170,14 +123,16 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco if (update) { // 클라이언트에서 온 업데이트 적용 Y.applyUpdate(doc, new Uint8Array(update)); - + // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 const state = Y.encodeStateAsUpdate(doc); this.server.to(`cardset:${cardsetId}`).emit('sync', { cardsetId, update: Array.from(state), }); - this.logger.log(`Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`); + this.logger.log( + `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, + ); } else { // 업데이트가 없으면 현재 상태를 요청한 클라이언트에게만 전송 const state = Y.encodeStateAsUpdate(doc); @@ -191,4 +146,4 @@ export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisco client.emit('error', { message: 'Sync failed' }); } } -} \ No newline at end of file +} From bf8ecb1d4d11526532ec47462a2500190d7cf071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 6 Nov 2025 12:19:14 +0900 Subject: [PATCH 11/35] =?UTF-8?q?Feat:=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 111 ++++++++++++++++++++++- package.json | 5 +- src/auth/ws-auth.guard.ts | 14 +++ src/card/card.module.ts | 4 + src/card/card.service.ts | 17 +++- src/cardset/cardset.module.ts | 4 + src/cardset/cardset.service.ts | 14 ++- src/websocket/websocket.gateway.ts | 77 +++++++++++----- src/websocket/websocket.module.ts | 7 +- src/websocket/yjs-document.service.ts | 126 ++++++++++++++++++++++++++ test-redis.ts | 82 +++++++++++++++++ 11 files changed, 429 insertions(+), 32 deletions(-) create mode 100644 src/websocket/yjs-document.service.ts create mode 100644 test-redis.ts diff --git a/package-lock.json b/package-lock.json index 74c2947..ed10165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.6", + "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", @@ -41,6 +42,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", + "dotenv": "^17.2.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", @@ -1370,6 +1372,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -4700,6 +4708,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5082,9 +5099,10 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5108,6 +5126,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6561,6 +6591,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7843,11 +7897,23 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -9020,6 +9086,27 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9678,6 +9765,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10690,6 +10783,18 @@ "ieee754": "^1.2.1" } }, + "node_modules/typeorm/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/typeorm/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", diff --git a/package.json b/package.json index d5e40d6..c29e3ac 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch --passWithNoTests", "test:cov": "jest --coverage --passWithNoTests", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --passWithNoTests", - "test:e2e": "jest --passWithNoTests --config ./test/jest-e2e.json" + "test:e2e": "jest --passWithNoTests --config ./test/jest-e2e.json", + "test:redis": "ts-node -r tsconfig-paths/register test-redis.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -29,6 +30,7 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.6", + "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", @@ -52,6 +54,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", + "dotenv": "^17.2.3", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/src/auth/ws-auth.guard.ts b/src/auth/ws-auth.guard.ts index 127d591..0a47617 100644 --- a/src/auth/ws-auth.guard.ts +++ b/src/auth/ws-auth.guard.ts @@ -16,6 +16,20 @@ export class WsAuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const client: Socket = context.switchToWs().getClient(); + // TODO: 테스트용 - JWT 인증 임시 비활성화 + // 실제 배포 시에는 아래 주석을 해제하고 테스트 코드를 제거해야 합니다 + const SKIP_AUTH = process.env.SKIP_WS_AUTH === 'true' || true; // 테스트용: true로 고정 + + if (SKIP_AUTH) { + // 테스트용 더미 사용자 데이터 + (client.data as { user: unknown }).user = { + userId: 'test-user', + email: 'test@example.com', + }; + this.logger.warn(`⚠️ 테스트 모드: 인증을 건너뛰고 있습니다 (client ${client.id})`); + return true; + } + // 1) socket.io v4: client.handshake.auth.token 로 받기 // 2) 또는 Authorization 헤더로 받기 const bearer = diff --git a/src/card/card.module.ts b/src/card/card.module.ts index 579e0cf..3f4afa8 100644 --- a/src/card/card.module.ts +++ b/src/card/card.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { CardService } from './card.service'; import { CardController } from './card.controller'; +import { Card } from './entities/card.entity'; @Module({ + imports: [TypeOrmModule.forFeature([Card])], controllers: [CardController], providers: [CardService], + exports: [CardService], }) export class CardModule {} diff --git a/src/card/card.service.ts b/src/card/card.service.ts index 5f7ecb9..0a8ff52 100644 --- a/src/card/card.service.ts +++ b/src/card/card.service.ts @@ -1,4 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Card } from './entities/card.entity'; @Injectable() -export class CardService {} +export class CardService { + constructor( + @InjectRepository(Card) + private readonly cardRepository: Repository, + ) {} + + async findByCardsetId(cardsetId: number): Promise { + return this.cardRepository.find({ + where: { cardSetId: cardsetId }, + order: { id: 'ASC' }, + }); + } +} diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index 2153f26..dfcdd0f 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { CardsetService } from './cardset.service'; import { CardsetController } from './cardset.controller'; +import { Cardset } from './entities/cardset.entity'; @Module({ + imports: [TypeOrmModule.forFeature([Cardset])], controllers: [CardsetController], providers: [CardsetService], + exports: [CardsetService], }) export class CardsetModule {} diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 927df7f..59e9c4a 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,4 +1,16 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cardset } from './entities/cardset.entity'; @Injectable() -export class CardsetService {} +export class CardsetService { + constructor( + @InjectRepository(Cardset) + private readonly cardsetRepository: Repository, + ) {} + + async findOne(id: number): Promise { + return this.cardsetRepository.findOne({ where: { id } }); + } +} diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 80340c2..c5122c9 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -13,6 +13,7 @@ import * as Y from 'yjs'; import { WsAuthGuard } from '../auth/ws-auth.guard'; import { WsUser } from '../decorators/ws-user.decorator'; import type { UserAuth } from '../types/userAuth.type'; +import { YjsDocumentService } from './yjs-document.service'; @UseGuards(WsAuthGuard) // 인증 가드 적용 @WebSocketGateway({ @@ -30,9 +31,9 @@ export class CollaborationGateway server: Server; private readonly logger = new Logger(CollaborationGateway.name); - private documentMap = new Map(); // cardsetId -> Y.Doc + private documentMap = new Map(); // cardsetId -> Y.Doc (메모리 캐시) - constructor() {} + constructor(private readonly yjsDocumentService: YjsDocumentService) {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); @@ -63,9 +64,30 @@ export class CollaborationGateway // 카드셋의 Yjs 문서 가져오기 또는 생성 let doc = this.documentMap.get(cardsetId); if (!doc) { - doc = new Y.Doc(); + // Redis에서 문서 로드 시도 + const loadedDoc = await this.yjsDocumentService.loadDocument(cardsetId); + if (loadedDoc) { + doc = loadedDoc; + this.logger.log( + `Loaded Yjs document from Redis for cardset ${cardsetId}`, + ); + } else { + // Redis에 없으면 새로 생성 + doc = new Y.Doc(); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); + + // 새로 생성한 문서를 Redis에 저장 + this.yjsDocumentService + .saveDocument(cardsetId, doc) + .catch((error) => { + this.logger.error( + `Failed to save new document to Redis for cardset ${cardsetId}:`, + error, + ); + }); + } + // 메모리 캐시에 저장 this.documentMap.set(cardsetId, doc); - this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); } // 클라이언트에게 현재 카드셋 상태 전송 @@ -103,7 +125,7 @@ export class CollaborationGateway // Yjs 업데이트 (클라이언트가 변경사항을 받을 때) @SubscribeMessage('update') - async handleSync( + async handleUpdate( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, @MessageBody() data: { cardsetId: string; update?: number[] }, @@ -120,27 +142,34 @@ export class CollaborationGateway return; } - if (update) { - // 클라이언트에서 온 업데이트 적용 - Y.applyUpdate(doc, new Uint8Array(update)); + if (!update) { + client.emit('error', { message: 'Update data is required' }); + return; + } - // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 - const state = Y.encodeStateAsUpdate(doc); - this.server.to(`cardset:${cardsetId}`).emit('sync', { - cardsetId, - update: Array.from(state), - }); - this.logger.log( - `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, - ); - } else { - // 업데이트가 없으면 현재 상태를 요청한 클라이언트에게만 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync', { - cardsetId, - update: Array.from(state), + // 클라이언트에서 온 업데이트 적용 + const updateBuffer = new Uint8Array(update); + Y.applyUpdate(doc, updateBuffer); + + // Redis에 업데이트 저장 (비동기, 에러 발생해도 계속 진행) + this.yjsDocumentService + .saveUpdate(cardsetId, updateBuffer) + .catch((error) => { + this.logger.error( + `Failed to save update to Redis for cardset ${cardsetId}:`, + error, + ); }); - } + + // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 + const state = Y.encodeStateAsUpdate(doc); + this.server.to(`cardset:${cardsetId}`).emit('sync', { + cardsetId, + update: Array.from(state), + }); + this.logger.log( + `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, + ); } catch (error) { this.logger.error('Error during sync:', error); client.emit('error', { message: 'Sync failed' }); diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts index a6c3327..efb9d4a 100644 --- a/src/websocket/websocket.module.ts +++ b/src/websocket/websocket.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { CollaborationGateway } from './websocket.gateway'; +import { YjsDocumentService } from './yjs-document.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ - providers: [CollaborationGateway], - exports: [], + imports: [AuthModule], + providers: [CollaborationGateway, YjsDocumentService], + exports: [YjsDocumentService], }) export class WebSocketModule {} diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts new file mode 100644 index 0000000..b0b84aa --- /dev/null +++ b/src/websocket/yjs-document.service.ts @@ -0,0 +1,126 @@ +import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import * as Y from 'yjs'; + +@Injectable() +export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(YjsDocumentService.name); + private redisClient: Redis; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + const redisHost = this.configService.get('REDIS_HOST') || 'localhost'; + const redisPort = this.configService.get('REDIS_PORT') || 6379; + const redisPassword = this.configService.get('REDIS_PASSWORD'); + + this.redisClient = new Redis({ + host: redisHost, + port: redisPort, + password: redisPassword, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + this.redisClient.on('connect', () => { + this.logger.log('Redis connected successfully'); + }); + + this.redisClient.on('error', (error) => { + this.logger.error('Redis connection error:', error); + }); + } + + onModuleDestroy() { + if (this.redisClient) { + this.redisClient.disconnect(); + this.logger.log('Redis disconnected'); + } + } + + /** + * Yjs 문서를 Redis에 저장 + */ + async saveDocument(cardsetId: string, doc: Y.Doc): Promise { + try { + const state = Y.encodeStateAsUpdate(doc); + const key = `yjs:cardset:${cardsetId}`; + await this.redisClient.set(key, Buffer.from(state)); + await this.redisClient.expire(key, 86400 * 7); // 7일 TTL + this.logger.debug(`Saved Yjs document for cardset ${cardsetId}`); + } catch (error) { + this.logger.error(`Failed to save document for cardset ${cardsetId}:`, error); + throw error; + } + } + + /** + * Redis에서 Yjs 문서를 로드 + */ + async loadDocument(cardsetId: string): Promise { + try { + const key = `yjs:cardset:${cardsetId}`; + const data = await this.redisClient.getBuffer(key); + + if (!data) { + return null; + } + + const doc = new Y.Doc(); + Y.applyUpdate(doc, data); + this.logger.debug(`Loaded Yjs document for cardset ${cardsetId}`); + return doc; + } catch (error) { + this.logger.error(`Failed to load document for cardset ${cardsetId}:`, error); + return null; + } + } + + /** + * Yjs 문서 업데이트를 Redis에 저장 (증분 업데이트) + */ + async saveUpdate(cardsetId: string, update: Uint8Array): Promise { + try { + const key = `yjs:cardset:${cardsetId}`; + const existingData = await this.redisClient.getBuffer(key); + + if (existingData) { + // 기존 문서에 업데이트 적용 + const doc = new Y.Doc(); + Y.applyUpdate(doc, existingData); + Y.applyUpdate(doc, update); + const newState = Y.encodeStateAsUpdate(doc); + await this.redisClient.set(key, Buffer.from(newState)); + await this.redisClient.expire(key, 86400 * 7); + } else { + // 문서가 없으면 새로 생성 + const doc = new Y.Doc(); + Y.applyUpdate(doc, update); + const state = Y.encodeStateAsUpdate(doc); + await this.redisClient.set(key, Buffer.from(state)); + await this.redisClient.expire(key, 86400 * 7); + } + } catch (error) { + this.logger.error(`Failed to save update for cardset ${cardsetId}:`, error); + throw error; + } + } + + /** + * Redis에서 문서 삭제 + */ + async deleteDocument(cardsetId: string): Promise { + try { + const key = `yjs:cardset:${cardsetId}`; + await this.redisClient.del(key); + this.logger.debug(`Deleted Yjs document for cardset ${cardsetId}`); + } catch (error) { + this.logger.error(`Failed to delete document for cardset ${cardsetId}:`, error); + throw error; + } + } +} + diff --git a/test-redis.ts b/test-redis.ts new file mode 100644 index 0000000..01e12d7 --- /dev/null +++ b/test-redis.ts @@ -0,0 +1,82 @@ +import Redis from 'ioredis'; +import * as Y from 'yjs'; +import { config } from 'dotenv'; + +// .env 파일 로드 +config(); + +async function testRedis() { + console.log('🔌 Redis 연결 테스트 시작...\n'); + + const redisHost = process.env.REDIS_HOST || 'localhost'; + const redisPort = Number(process.env.REDIS_PORT) || 6379; + const redisPassword = process.env.REDIS_PASSWORD; + + console.log(`📋 설정 정보:`); + console.log(` Host: ${redisHost}`); + console.log(` Port: ${redisPort}`); + console.log(` Password: ${redisPassword ? '***' : '(없음)'}\n`); + + const redisClient = new Redis({ + host: redisHost, + port: redisPort, + password: redisPassword, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + try { + // 연결 테스트 + console.log('1️⃣ 연결 테스트...'); + await redisClient.ping(); + console.log(' ✅ Redis 연결 성공!\n'); + + // Yjs 문서 저장 테스트 + console.log('2️⃣ Yjs 문서 저장 테스트...'); + const testDoc = new Y.Doc(); + const testArray = testDoc.getArray('test'); + testArray.push(['test1', 'test2', 'test3']); + + const state = Y.encodeStateAsUpdate(testDoc); + const testKey = 'yjs:cardset:test-123'; + await redisClient.set(testKey, Buffer.from(state)); + await redisClient.expire(testKey, 60); + console.log(' ✅ Yjs 문서 저장 성공!\n'); + + // Yjs 문서 로드 테스트 + console.log('3️⃣ Yjs 문서 로드 테스트...'); + const loadedData = await redisClient.getBuffer(testKey); + if (loadedData) { + const loadedDoc = new Y.Doc(); + Y.applyUpdate(loadedDoc, loadedData); + const loadedArray = loadedDoc.getArray('test'); + console.log(` ✅ 문서 로드 성공! 데이터: ${JSON.stringify(loadedArray.toArray())}\n`); + } else { + console.log(' ❌ 문서를 찾을 수 없습니다.\n'); + } + + // 테스트 데이터 삭제 + console.log('4️⃣ 테스트 데이터 정리...'); + await redisClient.del(testKey); + console.log(' ✅ 테스트 데이터 삭제 완료!\n'); + + console.log('🎉 모든 테스트 통과!'); + } catch (error) { + console.error('❌ 오류 발생:', error); + if (error instanceof Error) { + console.error(` 메시지: ${error.message}`); + } + } finally { + redisClient.disconnect(); + console.log('\n🔌 Redis 연결 종료'); + } +} + +// 환경변수 로드 (필요시) +import * as dotenv from 'dotenv'; +dotenv.config(); + +testRedis().catch(console.error); + From f324aed00b5b88ae1586713905ac1db39aa3b6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 26 Nov 2025 22:42:00 +0900 Subject: [PATCH 12/35] =?UTF-8?q?Feat:=20=ED=98=91=EC=97=85=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EB=82=B4=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 98 ++++ package.json | 8 +- src/app.module.ts | 12 +- src/auth/ws-auth.guard.ts | 4 +- src/cardset/cardset.controller.ts | 12 +- src/cardset/cardset.module.ts | 9 +- src/cardset/cardset.service.spec.ts | 36 +- src/cardset/cardset.service.ts | 87 ++- .../entities/cardset-content.entity.ts | 33 ++ .../entities/cardset-incremental.entity.ts | 41 ++ .../entities/cardset-snapshot.entity.ts | 30 ++ src/common/entities/base.entity.ts | 24 + .../websocket.gateway.integration.spec.ts | 193 +++++++ src/websocket/websocket.gateway.ts | 249 +++++++-- src/websocket/websocket.module.ts | 11 +- src/websocket/yjs-document.service.spec.ts | 48 ++ src/websocket/yjs-document.service.ts | 265 ++++++++- websocket-test.html | 509 ++++++++++++++++++ 18 files changed, 1590 insertions(+), 79 deletions(-) create mode 100644 src/cardset/entities/cardset-content.entity.ts create mode 100644 src/cardset/entities/cardset-incremental.entity.ts create mode 100644 src/cardset/entities/cardset-snapshot.entity.ts create mode 100644 src/common/entities/base.entity.ts create mode 100644 src/websocket/websocket.gateway.integration.spec.ts create mode 100644 src/websocket/yjs-document.service.spec.ts create mode 100644 websocket-test.html diff --git a/package-lock.json b/package-lock.json index ed10165..bdbf366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -5227,6 +5228,60 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -9589,6 +9644,40 @@ } } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -11500,6 +11589,15 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index c29e3ac..ddb7ccf 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "test:cov": "jest --coverage --passWithNoTests", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --passWithNoTests", "test:e2e": "jest --passWithNoTests --config ./test/jest-e2e.json", - "test:redis": "ts-node -r tsconfig-paths/register test-redis.ts" + "test:redis": "ts-node -r tsconfig-paths/register test-redis.ts", + "test:yjs": "ts-node -r tsconfig-paths/register test-yjs-integration.ts", + "test:websocket": "ts-node -r tsconfig-paths/register test-websocket-flow.ts", + "test:websocket:simple": "ts-node -r tsconfig-paths/register test-websocket-simple.ts", + "test:websocket:methods": "ts-node -r tsconfig-paths/register test-websocket-methods.ts", + "test:mysql": "ts-node -r tsconfig-paths/register test-mysql.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -61,6 +66,7 @@ "globals": "^16.0.0", "jest": "^30.0.0", "prettier": "^3.4.2", + "socket.io-client": "^4.8.1", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/src/app.module.ts b/src/app.module.ts index 17d7c3e..05e5b40 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,9 @@ import { AuthModule } from './auth/auth.module'; import { CardsetModule } from './cardset/cardset.module'; import { CardsetManagerModule } from './cardset-manager/cardset-manager.module'; import { Cardset as CardSet } from './cardset/entities/cardset.entity'; +import { CardsetSnapshot } from './cardset/entities/cardset-snapshot.entity'; +import { CardsetIncremental } from './cardset/entities/cardset-incremental.entity'; +import { CardsetContent } from './cardset/entities/cardset-content.entity'; import { CardsetManager as CardSetManager } from './cardset-manager/entities/cardset-manager.entity'; import { CardModule } from './card/card.module'; import { Card as Card } from './card/entities/card.entity'; @@ -20,7 +23,14 @@ import { WebSocketModule } from './websocket/websocket.module'; username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - entities: [CardSet, CardSetManager, Card], + entities: [ + CardSet, + CardsetSnapshot, + CardsetIncremental, + CardsetContent, + CardSetManager, + Card, + ], synchronize: false, }), CardsetModule, diff --git a/src/auth/ws-auth.guard.ts b/src/auth/ws-auth.guard.ts index 0a47617..38d3552 100644 --- a/src/auth/ws-auth.guard.ts +++ b/src/auth/ws-auth.guard.ts @@ -26,7 +26,9 @@ export class WsAuthGuard implements CanActivate { userId: 'test-user', email: 'test@example.com', }; - this.logger.warn(`⚠️ 테스트 모드: 인증을 건너뛰고 있습니다 (client ${client.id})`); + this.logger.warn( + `⚠️ 테스트 모드: 인증을 건너뛰고 있습니다 (client ${client.id})`, + ); return true; } diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index f4fd1c4..6d6026b 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -1,7 +1,15 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; import { CardsetService } from './cardset.service'; -@Controller('cardset') +@Controller('v1/card-sets') export class CardsetController { constructor(private readonly cardsetService: CardsetService) {} + + @Post(':cardSetId') + async saveCardsetSnapshot( + @Param('cardSetId', ParseIntPipe) cardSetId: number, + ) { + await this.cardsetService.saveCardsetContent(cardSetId); + return { success: true }; + } } diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index dfcdd0f..628cdc4 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,11 +1,16 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CardsetService } from './cardset.service'; import { CardsetController } from './cardset.controller'; import { Cardset } from './entities/cardset.entity'; +import { CardsetContent } from './entities/cardset-content.entity'; +import { WebSocketModule } from '../websocket/websocket.module'; @Module({ - imports: [TypeOrmModule.forFeature([Cardset])], + imports: [ + TypeOrmModule.forFeature([Cardset, CardsetContent]), + forwardRef(() => WebSocketModule), + ], controllers: [CardsetController], providers: [CardsetService], exports: [CardsetService], diff --git a/src/cardset/cardset.service.spec.ts b/src/cardset/cardset.service.spec.ts index 993a7fc..393fea8 100644 --- a/src/cardset/cardset.service.spec.ts +++ b/src/cardset/cardset.service.spec.ts @@ -1,12 +1,46 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { CardsetService } from './cardset.service'; +import { Cardset } from './entities/cardset.entity'; +import { CardsetContent } from './entities/cardset-content.entity'; +import { YjsDocumentService } from '../websocket/yjs-document.service'; describe('CardsetService', () => { let service: CardsetService; + const repositoryMockFactory = () => ({ + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }); + + let cardsetRepositoryMock: ReturnType; + let cardsetContentRepositoryMock: ReturnType; + let yjsDocumentServiceMock: { loadDocument: jest.Mock }; + beforeEach(async () => { + cardsetRepositoryMock = repositoryMockFactory(); + cardsetContentRepositoryMock = repositoryMockFactory(); + yjsDocumentServiceMock = { + loadDocument: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [CardsetService], + providers: [ + CardsetService, + { + provide: getRepositoryToken(Cardset), + useValue: cardsetRepositoryMock, + }, + { + provide: getRepositoryToken(CardsetContent), + useValue: cardsetContentRepositoryMock, + }, + { + provide: YjsDocumentService, + useValue: yjsDocumentServiceMock, + }, + ], }).compile(); service = module.get(CardsetService); diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 59e9c4a..71a4f95 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,16 +1,101 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as Y from 'yjs'; import { Cardset } from './entities/cardset.entity'; +import { CardsetContent } from './entities/cardset-content.entity'; +import { YjsDocumentService } from '../websocket/yjs-document.service'; @Injectable() export class CardsetService { constructor( @InjectRepository(Cardset) private readonly cardsetRepository: Repository, + @InjectRepository(CardsetContent) + private readonly cardsetContentRepository: Repository, + private readonly yjsDocumentService: YjsDocumentService, ) {} async findOne(id: number): Promise { return this.cardsetRepository.findOne({ where: { id } }); } + + /** + * DB에서 카드셋 내용을 로드하여 Y.Doc으로 변환 + * @param cardSetId 카드셋 ID + * @returns Y.Doc 객체 또는 null (DB에 없으면) + */ + async loadCardsetContentFromDB(cardSetId: number): Promise { + try { + const cardsetContent = await this.cardsetContentRepository.findOne({ + where: { cardset: { id: cardSetId } }, + }); + + if (!cardsetContent || !cardsetContent.content) { + return null; + } + + // JSON 문자열을 파싱 + const jsonContent = JSON.parse(cardsetContent.content) as Record< + string, + unknown + >; + + // Y.Doc 생성 및 JSON 데이터 적용 + const doc = new Y.Doc(); + if (jsonContent && typeof jsonContent === 'object') { + // Y.Doc에 JSON 데이터 적용 + // Yjs는 Map이나 Array 같은 구조화된 데이터를 사용하므로 + // 일반 JSON 객체를 Y.Map에 저장 + const yMap = doc.getMap('content'); + for (const [key, value] of Object.entries(jsonContent)) { + yMap.set(key, value); + } + } + + return doc; + } catch (error) { + // 테이블이 없거나 조회 실패 시 null 반환 (에러 throw 안 함) + return null; + } + } + + async saveCardsetContent(cardSetId: number): Promise { + //카드셋 없으면 에러 발생 + const cardset = await this.cardsetRepository.findOne({ + where: { id: cardSetId }, + }); + if (!cardset) { + throw new NotFoundException('Cardset not found'); + } + + //레디스에서 카드셋 스냅샷 로드 + const doc = await this.yjsDocumentService.loadDocument( + cardSetId.toString(), + ); + if (!doc) { + throw new NotFoundException('Cardset snapshot not found in Redis'); + } + + const jsonContent = JSON.stringify(doc.toJSON() ?? {}); + + //카드셋 내용 없으면 새로 생성 + let cardsetContent = await this.cardsetContentRepository.findOne({ + where: { cardset: { id: cardSetId } }, + }); + + //카드셋 내용 없으면 새로 생성 + if (!cardsetContent) { + cardsetContent = this.cardsetContentRepository.create({ + cardset, + content: '', + }); + } + + cardsetContent.content = jsonContent; + + await this.cardsetContentRepository.save(cardsetContent); + + await this.yjsDocumentService.flushIncrementalHistory(cardSetId.toString()); + } } diff --git a/src/cardset/entities/cardset-content.entity.ts b/src/cardset/entities/cardset-content.entity.ts new file mode 100644 index 0000000..df2371c --- /dev/null +++ b/src/cardset/entities/cardset-content.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Cardset } from './cardset.entity'; +import { BaseEntity } from '../../common/entities/base.entity'; + +/** + * 카드셋 내용 JSON 테이블 + * 카드 내용을 JSON 문자열로 저장 + * 예: {"question1": "content1", "question2": "content2"} + */ +@Entity('cardset_contents') +export class CardsetContent extends BaseEntity { + @PrimaryGeneratedColumn({ type: 'int' }) + id!: number; + + //1대1 관계 + @OneToOne(() => Cardset, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'cardset_id' }) + cardset?: Cardset; + + /** + * 카드셋의 내용을 JSON 문자열로 저장 + * 예: {"question1": "content1", "question2": "content2"} + * TEXT 또는 LONGTEXT 타입 사용 + */ + @Column({ type: 'longtext', nullable: false }) + content!: string; +} diff --git a/src/cardset/entities/cardset-incremental.entity.ts b/src/cardset/entities/cardset-incremental.entity.ts new file mode 100644 index 0000000..c71ca94 --- /dev/null +++ b/src/cardset/entities/cardset-incremental.entity.ts @@ -0,0 +1,41 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Cardset } from './cardset.entity'; +import { BaseEntity } from '../../common/entities/base.entity'; + +/** + * 카드셋 증분값 저장 테이블 + * 스케줄러로 자동 저장 (문서에 사람이 없을 때) + */ +@Entity('cardset_incrementals') +export class CardsetIncremental extends BaseEntity { + @PrimaryGeneratedColumn({ type: 'int' }) + id!: number; + + @ManyToOne(() => Cardset, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'cardset_id' }) + cardset?: Cardset; + + /** + * 증분값 (Yjs 업데이트 바이너리 데이터) + * BLOB 타입 사용 + */ + @Column({ name: 'incremental_value', type: 'blob', nullable: false }) + incrementalValue!: Buffer; + + /** + * MySQL 반영 여부 플래그 + */ + @Column({ + name: 'is_flushed', + type: 'boolean', + default: false, + nullable: false, + }) + isFlushed!: boolean; +} diff --git a/src/cardset/entities/cardset-snapshot.entity.ts b/src/cardset/entities/cardset-snapshot.entity.ts new file mode 100644 index 0000000..dd77018 --- /dev/null +++ b/src/cardset/entities/cardset-snapshot.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { Cardset } from './cardset.entity'; +import { BaseEntity } from '../../common/entities/base.entity'; + +/** + * 카드셋 최종 스냅샷 테이블 + * Redis에 저장된 최종 합쳐진 doc 내용을 저장 + */ +@Entity('cardset_snapshots') +export class CardsetSnapshot extends BaseEntity { + @PrimaryGeneratedColumn({ type: 'int' }) + id!: number; + + @OneToOne(() => Cardset, { createForeignKeyConstraints: false }) + @JoinColumn({ name: 'cardset_id' }) + cardset?: Cardset; + + /** + * Redis에서 합쳐진 최종 doc 내용 (Yjs 인코딩된 바이너리 데이터) + * BLOB 또는 MEDIUMBLOB 타입 사용 + */ + @Column({ name: 'doc_content', type: 'mediumblob', nullable: false }) + docContent!: Buffer; +} diff --git a/src/common/entities/base.entity.ts b/src/common/entities/base.entity.ts new file mode 100644 index 0000000..db4ce03 --- /dev/null +++ b/src/common/entities/base.entity.ts @@ -0,0 +1,24 @@ +import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +/** + * 생성시간과 수정시간을 포함한 Base Entity + */ +export abstract class BaseEntity { + /** + * 생성 시간 + */ + @CreateDateColumn({ + name: 'created_at', + type: 'datetime', + }) + createdAt!: Date; + + /** + * 수정 시간 + */ + @UpdateDateColumn({ + name: 'updated_at', + type: 'datetime', + }) + updatedAt!: Date; +} diff --git a/src/websocket/websocket.gateway.integration.spec.ts b/src/websocket/websocket.gateway.integration.spec.ts new file mode 100644 index 0000000..85ca767 --- /dev/null +++ b/src/websocket/websocket.gateway.integration.spec.ts @@ -0,0 +1,193 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CollaborationGateway } from './websocket.gateway'; +import { YjsDocumentService } from './yjs-document.service'; +import { CardsetService } from '../cardset/cardset.service'; +import { CardsetIncremental } from '../cardset/entities/cardset-incremental.entity'; +import { Cardset } from '../cardset/entities/cardset.entity'; +import { CardsetContent } from '../cardset/entities/cardset-content.entity'; +import { Server, Socket } from 'socket.io'; +import * as Y from 'yjs'; + +describe('CollaborationGateway Integration', () => { + let gateway: CollaborationGateway; + let yjsDocumentService: YjsDocumentService; + let cardsetService: CardsetService; + let mockSocket: Partial; + let mockServer: Partial; + + beforeEach(async () => { + mockSocket = { + id: 'test-client-1', + join: jest.fn(), + leave: jest.fn(), + emit: jest.fn(), + to: jest.fn().mockReturnThis(), + }; + + mockServer = { + to: jest.fn().mockReturnValue({ + emit: jest.fn(), + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CollaborationGateway, + YjsDocumentService, + CardsetService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'REDIS_HOST') return 'localhost'; + if (key === 'REDIS_PORT') return 6379; + if (key === 'YJS_MYSQL_FLUSH_DELAY_MS') return 1000; + return null; + }), + }, + }, + { + provide: getRepositoryToken(CardsetIncremental), + useValue: { + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Cardset), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(CardsetContent), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, + ], + }).compile(); + + gateway = module.get(CollaborationGateway); + yjsDocumentService = module.get(YjsDocumentService); + cardsetService = module.get(CardsetService); + + gateway.server = mockServer as Server; + }); + + it('should be defined', () => { + expect(gateway).toBeDefined(); + expect(yjsDocumentService).toBeDefined(); + expect(cardsetService).toBeDefined(); + }); + + describe('join-cardset flow', () => { + it('should load document from Redis when available', async () => { + const cardsetId = 'test-join-1'; + const user = { userId: 'user-1' }; + + // Redis에 문서 저장 + const doc = new Y.Doc(); + const testArray = doc.getArray('test'); + testArray.push(['data1']); + await yjsDocumentService.saveDocument(cardsetId, doc); + + // join-cardset 호출 + await gateway.handleJoinCardset( + user as any, + mockSocket as Socket, + { cardsetId }, + ); + + expect(mockSocket.join).toHaveBeenCalledWith(`cardset:${cardsetId}`); + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + + // 정리 + await yjsDocumentService.deleteDocument(cardsetId); + }); + + it('should load from DB when Redis is empty', async () => { + const cardsetId = 'test-join-2'; + const numericId = 2; + const user = { userId: 'user-2' }; + + // DB에서 로드하는 메서드 모킹 + const dbDoc = new Y.Doc(); + const dbArray = dbDoc.getArray('db-data'); + dbArray.push(['from-db']); + jest + .spyOn(cardsetService, 'loadCardsetContentFromDB') + .mockResolvedValue(dbDoc); + + await gateway.handleJoinCardset( + user as any, + mockSocket as Socket, + { cardsetId }, + ); + + expect(cardsetService.loadCardsetContentFromDB).toHaveBeenCalledWith( + numericId, + ); + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + + // 정리 + await yjsDocumentService.deleteDocument(cardsetId); + }); + + it('should create new document when both Redis and DB are empty', async () => { + const cardsetId = 'test-join-3'; + const user = { userId: 'user-3' }; + + // DB에서도 null 반환 + jest + .spyOn(cardsetService, 'loadCardsetContentFromDB') + .mockResolvedValue(null); + + await gateway.handleJoinCardset( + user as any, + mockSocket as Socket, + { cardsetId }, + ); + + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + + // 정리 + await yjsDocumentService.deleteDocument(cardsetId); + }); + }); + + describe('update flow', () => { + it('should apply update and broadcast to all clients', async () => { + const cardsetId = 'test-update-1'; + const user = { userId: 'user-4' }; + + // 문서 생성 + const doc = new Y.Doc(); + await yjsDocumentService.saveDocument(cardsetId, doc); + + // 업데이트 데이터 + const update = new Uint8Array([1, 2, 3, 4, 5]); + const updateArray = Array.from(update); + + await gateway.handleUpdate( + user as any, + mockSocket as Socket, + { cardsetId, update: updateArray }, + ); + + expect(mockServer.to).toHaveBeenCalledWith(`cardset:${cardsetId}`); + expect(mockServer.to().emit).toHaveBeenCalledWith( + 'sync', + expect.any(Object), + ); + + // 정리 + await yjsDocumentService.deleteDocument(cardsetId); + }); + }); +}); + diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index c5122c9..ab9b3d2 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -7,13 +7,14 @@ import { OnGatewayConnection, OnGatewayDisconnect, } from '@nestjs/websockets'; -import { Logger, UseGuards } from '@nestjs/common'; +import { Inject, Logger, UseGuards, forwardRef } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; import * as Y from 'yjs'; import { WsAuthGuard } from '../auth/ws-auth.guard'; import { WsUser } from '../decorators/ws-user.decorator'; import type { UserAuth } from '../types/userAuth.type'; import { YjsDocumentService } from './yjs-document.service'; +import { CardsetService } from '../cardset/cardset.service'; @UseGuards(WsAuthGuard) // 인증 가드 적용 @WebSocketGateway({ @@ -30,21 +31,24 @@ export class CollaborationGateway @WebSocketServer() server: Server; + private static readonly FLUSH_DELAY_MS = 5000; + private readonly logger = new Logger(CollaborationGateway.name); - private documentMap = new Map(); // cardsetId -> Y.Doc (메모리 캐시) + private flushTimeouts = new Map(); - constructor(private readonly yjsDocumentService: YjsDocumentService) {} + constructor( + private readonly yjsDocumentService: YjsDocumentService, + @Inject(forwardRef(() => CardsetService)) + private readonly cardsetService: CardsetService, + ) {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); } - handleDisconnect(client: Socket) { + async handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - // 클라이언트가 연결된 모든 카드셋에서 나가기 - for (const [cardsetId] of this.documentMap) { - void client.leave(`cardset:${cardsetId}`); - } + await this.removeClientFromAllCardsets(client); } // 카드셋에 조인 (카드셋의 Yjs 문서에 접근) @@ -54,43 +58,33 @@ export class CollaborationGateway @ConnectedSocket() client: Socket, @MessageBody() data: { cardsetId: string }, ) { - try { - const { cardsetId } = data; - this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); + const { cardsetId } = data; + this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); + try { // 카드셋 룸에 조인 void client.join(`cardset:${cardsetId}`); - // 카드셋의 Yjs 문서 가져오기 또는 생성 - let doc = this.documentMap.get(cardsetId); + // TODO: 카드셋 룸에 조인 시 클라이언트 등록 및 플러시 스케줄링 기능 + // await this.yjsDocumentService.registerClient(cardsetId, client.id); + // this.clearScheduledFlush(cardsetId); + + // Redis에서 문서 로드 시도 + let doc = await this.yjsDocumentService.loadDocument(cardsetId); if (!doc) { - // Redis에서 문서 로드 시도 - const loadedDoc = await this.yjsDocumentService.loadDocument(cardsetId); - if (loadedDoc) { - doc = loadedDoc; - this.logger.log( - `Loaded Yjs document from Redis for cardset ${cardsetId}`, - ); - } else { - // Redis에 없으면 새로 생성 - doc = new Y.Doc(); - this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); - - // 새로 생성한 문서를 Redis에 저장 - this.yjsDocumentService - .saveDocument(cardsetId, doc) - .catch((error) => { - this.logger.error( - `Failed to save new document to Redis for cardset ${cardsetId}:`, - error, - ); - }); - } - // 메모리 캐시에 저장 - this.documentMap.set(cardsetId, doc); + // Redis에 없으면 DB에서 확인 + doc = await this.loadDocumentFromDBOrCreate(cardsetId); } - // 클라이언트에게 현재 카드셋 상태 전송 + // 문서가 없으면 새로 생성 (최후의 수단) + if (!doc) { + this.logger.warn( + `Failed to load or create document for cardset ${cardsetId}, creating empty document`, + ); + doc = new Y.Doc(); + } + + // 클라이언트에게 현재 카드셋 상태 전송 -> 직렬화 const state = Y.encodeStateAsUpdate(doc); client.emit('sync', { @@ -101,13 +95,37 @@ export class CollaborationGateway this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); } catch (error) { this.logger.error('Error joining cardset:', error); - client.emit('error', { message: 'Failed to join cardset' }); + this.logger.error('Error details:', { + cardsetId, + userId: user?.userId, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + }); + + // 에러가 발생해도 빈 문서라도 보내서 클라이언트가 연결 유지할 수 있도록 + try { + const emptyDoc = new Y.Doc(); + const state = Y.encodeStateAsUpdate(emptyDoc); + client.emit('sync', { + cardsetId, + update: Array.from(state), + }); + this.logger.warn( + `Sent empty document to client due to error for cardset ${cardsetId}`, + ); + } catch (fallbackError) { + this.logger.error('Failed to send fallback document:', fallbackError); + client.emit('error', { + message: 'Failed to join cardset', + details: error instanceof Error ? error.message : String(error), + }); + } } } // 카드셋에서 나가기 @SubscribeMessage('leave-cardset') - async handleLeaveCardset( + handleLeaveCardset( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, @MessageBody() data: { cardsetId: string }, @@ -117,12 +135,31 @@ export class CollaborationGateway this.logger.log(`User ${user.userId} leaving cardset ${cardsetId}`); void client.leave(`cardset:${cardsetId}`); + // await this.yjsDocumentService.unregisterClient(cardsetId, client.id); + // const activeCount = + // await this.yjsDocumentService.getActiveClientCount(cardsetId); + // if (activeCount === 0) { + // this.scheduleFlush(cardsetId); + // } this.logger.log(`User ${user.userId} left cardset ${cardsetId}`); } catch (error) { this.logger.error('Error leaving cardset:', error); } } + @SubscribeMessage('awareness') // ← 클라이언트가 보낸 "awareness" 받음 + handleAwareness( + client: Socket, + payload: { documentId: string; awareness: Uint8Array }, + ) { + const { documentId, awareness } = payload; + + // 같은 문서에 있는 클라이언트에 "awareness"로 브로드캐스트 + client.to(documentId).emit('awareness', { + data: { documentId, awareness: new Uint8Array(awareness) }, + }); + } + // Yjs 업데이트 (클라이언트가 변경사항을 받을 때) @SubscribeMessage('update') async handleUpdate( @@ -136,36 +173,32 @@ export class CollaborationGateway `Sync request from user ${user.userId} for cardset ${cardsetId}`, ); - const doc = this.documentMap.get(cardsetId); - if (!doc) { - client.emit('error', { message: 'Cardset not found' }); - return; - } - if (!update) { client.emit('error', { message: 'Update data is required' }); return; } - // 클라이언트에서 온 업데이트 적용 + // Redis에서 문서 로드 + let doc = await this.yjsDocumentService.loadDocument(cardsetId); + if (!doc) { + doc = new Y.Doc(); + this.logger.log( + `Created new Yjs document for cardset ${cardsetId} during update`, + ); + } + + // 클라이언트에서 온 업데이트 적용 -> 증분값 const updateBuffer = new Uint8Array(update); Y.applyUpdate(doc, updateBuffer); - // Redis에 업데이트 저장 (비동기, 에러 발생해도 계속 진행) - this.yjsDocumentService - .saveUpdate(cardsetId, updateBuffer) - .catch((error) => { - this.logger.error( - `Failed to save update to Redis for cardset ${cardsetId}:`, - error, - ); - }); + // Redis에 업데이트 저장 (증분값과 스냅샷 모두 저장) + await this.yjsDocumentService.saveUpdate(cardsetId, updateBuffer); // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 const state = Y.encodeStateAsUpdate(doc); this.server.to(`cardset:${cardsetId}`).emit('sync', { cardsetId, - update: Array.from(state), + update: state, }); this.logger.log( `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, @@ -175,4 +208,106 @@ export class CollaborationGateway client.emit('error', { message: 'Sync failed' }); } } + + /** + * DB에서 문서를 로드하거나 없으면 새로 생성 + * DB에서 로드한 경우 Redis에 저장 + */ + private async loadDocumentFromDBOrCreate(cardsetId: string): Promise { + const numericCardsetId = Number(cardsetId); + + try { + // DB에서 로드 시도 + const doc = + await this.cardsetService.loadCardsetContentFromDB(numericCardsetId); + if (doc) { + // DB에서 로드한 문서를 Redis에 저장 (실패해도 계속 진행) + await this.yjsDocumentService.saveDocument(cardsetId, doc).catch( + (error) => { + this.logger.warn( + `Failed to save document to Redis after DB load: ${error}`, + ); + }, + ); + this.logger.log( + `Loaded Yjs document from DB and saved to Redis for cardset ${cardsetId}`, + ); + return doc; + } + } catch (error) { + this.logger.warn( + `Failed to load from DB for cardset ${cardsetId}, creating new document: ${error}`, + ); + } + + // DB에도 없거나 에러 발생 시 새로 생성 + return this.createNewDocument(cardsetId); + } + + /** + * 새 Yjs 문서를 생성하고 Redis에 저장 + */ + private async createNewDocument(cardsetId: string): Promise { + const doc = new Y.Doc(); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); + // Redis 저장 실패해도 문서는 반환 (메모리에서 사용 가능) + await this.yjsDocumentService.saveDocument(cardsetId, doc).catch( + (error) => { + this.logger.warn( + `Failed to save new document to Redis: ${error}, continuing anyway`, + ); + }, + ); + return doc; + } + + private async removeClientFromAllCardsets(client: Socket) { + const cardsets = await this.yjsDocumentService.getClientCardsets(client.id); + if (cardsets.length === 0) { + return; + } + for (const cardsetId of cardsets) { + void client.leave(`cardset:${cardsetId}`); + await this.yjsDocumentService.unregisterClient(cardsetId, client.id); + const activeCount = + await this.yjsDocumentService.getActiveClientCount(cardsetId); + if (activeCount === 0) { + this.scheduleFlush(cardsetId); + } + } + } + + private scheduleFlush(cardsetId: string) { + if (this.flushTimeouts.has(cardsetId)) { + return; + } + const timeout = setTimeout(() => { + this.flushTimeouts.delete(cardsetId); + void this.flushCardset(cardsetId); + }, CollaborationGateway.FLUSH_DELAY_MS); + this.flushTimeouts.set(cardsetId, timeout); + this.logger.log(`Scheduled cardset ${cardsetId} flush`); + } + + private clearScheduledFlush(cardsetId: string) { + const timeout = this.flushTimeouts.get(cardsetId); + if (timeout) { + clearTimeout(timeout); + this.flushTimeouts.delete(cardsetId); + } + } + + private async flushCardset(cardsetId: string) { + const activeCount = + await this.yjsDocumentService.getActiveClientCount(cardsetId); + if (activeCount > 0) { + return; + } + try { + await this.cardsetService.saveCardsetContent(Number(cardsetId)); + this.logger.log(`Flushed cardset ${cardsetId} snapshot to database`); + } catch (error) { + this.logger.error(`Failed to flush cardset ${cardsetId}:`, error); + } + } } diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts index efb9d4a..673a486 100644 --- a/src/websocket/websocket.module.ts +++ b/src/websocket/websocket.module.ts @@ -1,10 +1,17 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { CollaborationGateway } from './websocket.gateway'; import { YjsDocumentService } from './yjs-document.service'; import { AuthModule } from '../auth/auth.module'; +import { CardsetModule } from '../cardset/cardset.module'; +import { CardsetIncremental } from '../cardset/entities/cardset-incremental.entity'; @Module({ - imports: [AuthModule], + imports: [ + AuthModule, + forwardRef(() => CardsetModule), + TypeOrmModule.forFeature([CardsetIncremental]), + ], providers: [CollaborationGateway, YjsDocumentService], exports: [YjsDocumentService], }) diff --git a/src/websocket/yjs-document.service.spec.ts b/src/websocket/yjs-document.service.spec.ts new file mode 100644 index 0000000..6329865 --- /dev/null +++ b/src/websocket/yjs-document.service.spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { YjsDocumentService } from './yjs-document.service'; +import { CardsetIncremental } from '../cardset/entities/cardset-incremental.entity'; + +describe('YjsDocumentService', () => { + let service: YjsDocumentService; + let repositoryMock: any; + + beforeEach(async () => { + repositoryMock = { + create: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + YjsDocumentService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'REDIS_HOST') return 'localhost'; + if (key === 'REDIS_PORT') return 6379; + if (key === 'YJS_MYSQL_FLUSH_DELAY_MS') return 1000; + return null; + }), + }, + }, + { + provide: getRepositoryToken(CardsetIncremental), + useValue: repositoryMock, + }, + ], + }).compile(); + + service = module.get(YjsDocumentService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // 실제 Redis 연결이 필요한 테스트는 test-yjs-integration.ts에서 실행 + // Jest 유닛 테스트는 서비스 구조만 확인 +}); + diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts index b0b84aa..5a5f747 100644 --- a/src/websocket/yjs-document.service.ts +++ b/src/websocket/yjs-document.service.ts @@ -1,17 +1,36 @@ -import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + Logger, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; import Redis from 'ioredis'; import * as Y from 'yjs'; +import { Repository } from 'typeorm'; +import { CardsetIncremental } from '../cardset/entities/cardset-incremental.entity'; +import { Cardset } from '../cardset/entities/cardset.entity'; @Injectable() export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(YjsDocumentService.name); private redisClient: Redis; + private readonly mysqlFlushDelayMs: number; + private readonly flushTimeouts = new Map(); - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + @InjectRepository(CardsetIncremental) + private readonly cardsetIncrementalRepository: Repository, + ) { + this.mysqlFlushDelayMs = + this.configService.get('YJS_MYSQL_FLUSH_DELAY_MS') || 60000; + } onModuleInit() { - const redisHost = this.configService.get('REDIS_HOST') || 'localhost'; + const redisHost = + this.configService.get('REDIS_HOST') || 'localhost'; const redisPort = this.configService.get('REDIS_PORT') || 6379; const redisPassword = this.configService.get('REDIS_PASSWORD'); @@ -39,6 +58,10 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { this.redisClient.disconnect(); this.logger.log('Redis disconnected'); } + + // Clean up pending debounce timers + this.flushTimeouts.forEach((timeout) => clearTimeout(timeout)); + this.flushTimeouts.clear(); } /** @@ -46,14 +69,26 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { */ async saveDocument(cardsetId: string, doc: Y.Doc): Promise { try { + // Redis 클라이언트가 초기화되지 않았으면 경고만 출력하고 계속 진행 + if (!this.redisClient) { + this.logger.warn( + `Redis client not initialized, skipping save for cardset ${cardsetId}`, + ); + return; + } + const state = Y.encodeStateAsUpdate(doc); const key = `yjs:cardset:${cardsetId}`; await this.redisClient.set(key, Buffer.from(state)); await this.redisClient.expire(key, 86400 * 7); // 7일 TTL this.logger.debug(`Saved Yjs document for cardset ${cardsetId}`); } catch (error) { - this.logger.error(`Failed to save document for cardset ${cardsetId}:`, error); - throw error; + this.logger.error( + `Failed to save document for cardset ${cardsetId}:`, + error, + ); + // Redis 저장 실패해도 에러를 throw하지 않고 계속 진행 + // (문서는 메모리에서 사용 가능) } } @@ -62,9 +97,17 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { */ async loadDocument(cardsetId: string): Promise { try { + // Redis 클라이언트가 초기화되지 않았으면 null 반환 + if (!this.redisClient) { + this.logger.warn( + `Redis client not initialized for cardset ${cardsetId}`, + ); + return null; + } + const key = `yjs:cardset:${cardsetId}`; const data = await this.redisClient.getBuffer(key); - + if (!data) { return null; } @@ -74,7 +117,10 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { this.logger.debug(`Loaded Yjs document for cardset ${cardsetId}`); return doc; } catch (error) { - this.logger.error(`Failed to load document for cardset ${cardsetId}:`, error); + this.logger.error( + `Failed to load document for cardset ${cardsetId}:`, + error, + ); return null; } } @@ -84,9 +130,19 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { */ async saveUpdate(cardsetId: string, update: Uint8Array): Promise { try { + // Redis 클라이언트가 초기화되지 않았으면 경고만 출력하고 계속 진행 + if (!this.redisClient) { + this.logger.warn( + `Redis client not initialized, skipping update save for cardset ${cardsetId}`, + ); + return; + } + const key = `yjs:cardset:${cardsetId}`; + const historyKey = `yjs:cardset:${cardsetId}:updates`; + const updateBuffer = Buffer.from(update); const existingData = await this.redisClient.getBuffer(key); - + if (existingData) { // 기존 문서에 업데이트 적용 const doc = new Y.Doc(); @@ -103,12 +159,107 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { await this.redisClient.set(key, Buffer.from(state)); await this.redisClient.expire(key, 86400 * 7); } + + // 증분 업데이트 내역을 별도 리스트로 보관 + await this.redisClient.rpush(historyKey, updateBuffer.toString('base64')); + + // 문서 업데이트가 일정 시간 동안 없으면 MySQL에 저장 + this.scheduleMySqlPersistence(cardsetId); } catch (error) { - this.logger.error(`Failed to save update for cardset ${cardsetId}:`, error); + this.logger.error( + `Failed to save update for cardset ${cardsetId}:`, + error, + ); throw error; } } + /** + * 디바운스 타이머를 설정하여 일정 시간 동안 업데이트가 없으면 + * Redis의 증분 업데이트 리스트를 MySQL에 자동 저장 + * @param cardsetId 카드셋 ID + */ + private scheduleMySqlPersistence(cardsetId: string) { + // 기존 타이머가 있으면 취소 (새로운 업데이트가 들어왔으므로) + const existingTimeout = this.flushTimeouts.get(cardsetId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // 일정 시간 후 자동으로 MySQL에 저장 (isFlushed = false) + const timeoutHandle = setTimeout(() => { + void this.persistCardsetIncrementals(cardsetId, false); + }, this.mysqlFlushDelayMs); + + this.flushTimeouts.set(cardsetId, timeoutHandle); + } + + /** + * 수동으로 증분 업데이트 리스트를 MySQL에 저장 + * 스냅샷 저장 시 호출되어 확정된 증분값으로 저장 (isFlushed = true) + * @param cardsetId 카드셋 ID + */ + async flushIncrementalHistory(cardsetId: string): Promise { + await this.persistCardsetIncrementals(cardsetId, true); + } + + /** + * Redis에 저장된 증분 업데이트 리스트를 MySQL cardset_incrementals 테이블에 저장 + * @param cardsetId 카드셋 ID + * @param markAsFlushed true: 스냅샷과 함께 확정 저장, false: 자동 저장 (미확정) + */ + private async persistCardsetIncrementals( + cardsetId: string, + markAsFlushed: boolean, + ): Promise { + // 타이머 정리 + this.flushTimeouts.delete(cardsetId); + + const numericCardsetId = Number(cardsetId); + if (Number.isNaN(numericCardsetId)) { + this.logger.error( + `Cannot persist cardset ${cardsetId} to MySQL: invalid numeric id`, + ); + return; + } + + const historyKey = `yjs:cardset:${cardsetId}:updates`; + + try { + // Redis에서 증분 업데이트 리스트 조회 + const updates = await this.redisClient.lrange(historyKey, 0, -1); + if (updates.length === 0) { + this.logger.debug( + `No incremental updates to persist for cardset ${cardsetId}`, + ); + return; + } + + // base64로 저장된 증분값을 Buffer로 변환하여 엔티티 생성 + const entities = updates.map((base64Value) => + this.cardsetIncrementalRepository.create({ + cardset: { id: numericCardsetId } as Cardset, + incrementalValue: Buffer.from(base64Value, 'base64'), + isFlushed: markAsFlushed, + }), + ); + + // MySQL에 저장 + await this.cardsetIncrementalRepository.save(entities); + // 저장 완료 후 Redis 리스트 삭제 + await this.redisClient.del(historyKey); + + this.logger.log( + `Persisted ${entities.length} incremental updates for cardset ${cardsetId} to MySQL`, + ); + } catch (error) { + this.logger.error( + `Failed to persist incremental updates for cardset ${cardsetId}:`, + error, + ); + } + } + /** * Redis에서 문서 삭제 */ @@ -118,9 +269,101 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { await this.redisClient.del(key); this.logger.debug(`Deleted Yjs document for cardset ${cardsetId}`); } catch (error) { - this.logger.error(`Failed to delete document for cardset ${cardsetId}:`, error); + this.logger.error( + `Failed to delete document for cardset ${cardsetId}:`, + error, + ); throw error; } } -} + /** + * 카드셋에 클라이언트 등록 (Redis Set 사용) + */ + async registerClient(cardsetId: string, clientId: string): Promise { + try { + const cardsetKey = `yjs:cardset:${cardsetId}:clients`; + const clientKey = `yjs:client:${clientId}:cardsets`; + await Promise.all([ + this.redisClient.sadd(cardsetKey, clientId), + this.redisClient.sadd(clientKey, cardsetId), + this.redisClient.expire(cardsetKey, 86400), // 24시간 TTL + this.redisClient.expire(clientKey, 86400), + ]); + } catch (error) { + this.logger.error( + `Failed to register client ${clientId} for cardset ${cardsetId}:`, + error, + ); + } + } + + /** + * 카드셋에서 클라이언트 해제 (Redis Set 사용) + */ + async unregisterClient(cardsetId: string, clientId: string): Promise { + try { + const cardsetKey = `yjs:cardset:${cardsetId}:clients`; + const clientKey = `yjs:client:${clientId}:cardsets`; + await Promise.all([ + this.redisClient.srem(cardsetKey, clientId), + this.redisClient.srem(clientKey, cardsetId), + ]); + } catch (error) { + this.logger.error( + `Failed to unregister client ${clientId} from cardset ${cardsetId}:`, + error, + ); + } + } + + /** + * 카드셋의 활성 클라이언트 수 조회 + */ + async getActiveClientCount(cardsetId: string): Promise { + try { + const cardsetKey = `yjs:cardset:${cardsetId}:clients`; + return await this.redisClient.scard(cardsetKey); + } catch (error) { + this.logger.error( + `Failed to get active client count for cardset ${cardsetId}:`, + error, + ); + return 0; + } + } + + /** + * 클라이언트가 참여 중인 모든 카드셋 조회 + */ + async getClientCardsets(clientId: string): Promise { + try { + const clientKey = `yjs:client:${clientId}:cardsets`; + return await this.redisClient.smembers(clientKey); + } catch (error) { + this.logger.error( + `Failed to get cardsets for client ${clientId}:`, + error, + ); + return []; + } + } + + /** + * 클라이언트의 모든 카드셋에서 해제 + */ + async unregisterClientFromAllCardsets(clientId: string): Promise { + try { + const cardsets = await this.getClientCardsets(clientId); + const promises = cardsets.map((cardsetId) => + this.unregisterClient(cardsetId, clientId), + ); + await Promise.all(promises); + } catch (error) { + this.logger.error( + `Failed to unregister client ${clientId} from all cardsets:`, + error, + ); + } + } +} diff --git a/websocket-test.html b/websocket-test.html new file mode 100644 index 0000000..5734421 --- /dev/null +++ b/websocket-test.html @@ -0,0 +1,509 @@ + + + + + + WebSocket 테스트 - Flip Note + + + + + +
+
+

🔌 WebSocket 연결 테스트

+

Flip Note 카드셋 협업 기능 테스트

+
+ +
+ +
+

📡 연결 설정

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
연결 안됨
+
+ + +
+

🎮 카드셋 조작

+
+ + +
+
+ + +
+

📝 Yjs 문서 테스트

+
+ + +
+
+ + +
+
+ + +
+

📋 이벤트 로그

+
+ +
+
+
+ + + + + From 6b619b3c04f82323e221e353aef9f984f4a2b3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 26 Nov 2025 22:42:13 +0900 Subject: [PATCH 13/35] =?UTF-8?q?Chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-mysql.ts | 162 +++++++++++++++++ test-websocket-methods.ts | 366 ++++++++++++++++++++++++++++++++++++++ test-websocket-simple.ts | 108 +++++++++++ test-yjs-integration.ts | 105 +++++++++++ 4 files changed, 741 insertions(+) create mode 100644 test-mysql.ts create mode 100644 test-websocket-methods.ts create mode 100644 test-websocket-simple.ts create mode 100644 test-yjs-integration.ts diff --git a/test-mysql.ts b/test-mysql.ts new file mode 100644 index 0000000..22baad1 --- /dev/null +++ b/test-mysql.ts @@ -0,0 +1,162 @@ +/** + * MySQL 연결 테스트 + */ +import { config } from 'dotenv'; +import { createConnection, Connection } from 'typeorm'; +import { Cardset } from './src/cardset/entities/cardset.entity'; +import { CardsetContent } from './src/cardset/entities/cardset-content.entity'; +import { CardsetIncremental } from './src/cardset/entities/cardset-incremental.entity'; +import { CardsetSnapshot } from './src/cardset/entities/cardset-snapshot.entity'; + +config(); + +async function testMySQLConnection() { + console.log('🧪 MySQL 연결 테스트 시작...\n'); + + const dbHost = process.env.DB_HOST || 'localhost'; + const dbPort = Number(process.env.DB_PORT) || 3306; + const dbUsername = process.env.DB_USERNAME || 'root'; + const dbPassword = process.env.DB_PASSWORD || ''; + const dbDatabase = process.env.DB_DATABASE || 'flipnote'; + + console.log('📋 설정 정보:'); + console.log(` Host: ${dbHost}`); + console.log(` Port: ${dbPort}`); + console.log(` Username: ${dbUsername}`); + console.log(` Database: ${dbDatabase}`); + console.log(` Password: ${dbPassword ? '***' : '(없음)'}\n`); + + let connection: Connection | null = null; + + try { + // 1. 연결 테스트 + console.log('1️⃣ MySQL 연결 테스트...'); + connection = await createConnection({ + type: 'mysql', + host: dbHost, + port: dbPort, + username: dbUsername, + password: dbPassword, + database: dbDatabase, + entities: [Cardset, CardsetContent, CardsetIncremental, CardsetSnapshot], + synchronize: false, + logging: false, + }); + + console.log(' ✅ MySQL 연결 성공!\n'); + + // 2. 테이블 존재 확인 + console.log('2️⃣ 테이블 존재 확인...'); + const queryRunner = connection.createQueryRunner(); + + const tables = [ + 'card_set', + 'cardset_contents', + 'cardset_incrementals', + 'cardset_snapshots', + ]; + + for (const tableName of tables) { + const tableExists = await queryRunner.hasTable(tableName); + console.log( + ` ${tableExists ? '✅' : '❌'} ${tableName}: ${tableExists ? '존재' : '없음'}`, + ); + } + console.log(''); + + // 3. cardset_contents 테이블 구조 확인 + console.log('3️⃣ cardset_contents 테이블 구조 확인...'); + const cardsetContentTable = await queryRunner.getTable('cardset_contents'); + if (cardsetContentTable) { + console.log(' ✅ 테이블 존재'); + console.log(` 컬럼 수: ${cardsetContentTable.columns.length}`); + cardsetContentTable.columns.forEach((col) => { + console.log(` - ${col.name} (${col.type})`); + }); + } else { + console.log(' ❌ 테이블 없음'); + } + console.log(''); + + // 4. cardset_incrementals 테이블 구조 확인 + console.log('4️⃣ cardset_incrementals 테이블 구조 확인...'); + const incrementalTable = await queryRunner.getTable('cardset_incrementals'); + if (incrementalTable) { + console.log(' ✅ 테이블 존재'); + console.log(` 컬럼 수: ${incrementalTable.columns.length}`); + incrementalTable.columns.forEach((col) => { + console.log(` - ${col.name} (${col.type})`); + }); + } else { + console.log(' ❌ 테이블 없음'); + } + console.log(''); + + // 5. 샘플 데이터 조회 테스트 + console.log('5️⃣ 샘플 데이터 조회 테스트...'); + try { + const cardsetRepository = connection.getRepository(Cardset); + const count = await cardsetRepository.count(); + console.log(` ✅ cardset 테이블 레코드 수: ${count}`); + } catch (error) { + console.log(` ⚠️ cardset 조회 실패: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + const contentRepository = connection.getRepository(CardsetContent); + const count = await contentRepository.count(); + console.log(` ✅ cardset_contents 테이블 레코드 수: ${count}`); + } catch (error) { + console.log(` ⚠️ cardset_contents 조회 실패: ${error instanceof Error ? error.message : String(error)}`); + } + + try { + const incrementalRepository = connection.getRepository(CardsetIncremental); + const count = await incrementalRepository.count(); + console.log(` ✅ cardset_incrementals 테이블 레코드 수: ${count}`); + } catch (error) { + console.log(` ⚠️ cardset_incrementals 조회 실패: ${error instanceof Error ? error.message : String(error)}`); + } + console.log(''); + + // 6. INSERT 테스트 (트랜잭션으로 롤백) + console.log('6️⃣ INSERT 테스트 (트랜잭션 롤백)...'); + const queryRunner2 = connection.createQueryRunner(); + await queryRunner2.connect(); + await queryRunner2.startTransaction(); + + try { + // 테스트용 INSERT (실제로는 저장하지 않음) + await queryRunner2.query( + 'SELECT 1 as test', + ); + console.log(' ✅ 쿼리 실행 성공'); + await queryRunner2.rollbackTransaction(); + console.log(' ✅ 트랜잭션 롤백 성공\n'); + } catch (error) { + await queryRunner2.rollbackTransaction(); + throw error; + } finally { + await queryRunner2.release(); + } + + console.log('🎉 모든 MySQL 테스트 통과!'); + } catch (error) { + console.error('❌ 오류 발생:', error); + if (error instanceof Error) { + console.error(` 메시지: ${error.message}`); + if (error.stack) { + console.error(` 스택: ${error.stack.split('\n').slice(0, 5).join('\n')}`); + } + } + process.exit(1); + } finally { + if (connection && connection.isConnected) { + await connection.close(); + console.log('\n🔌 MySQL 연결 종료'); + } + } +} + +testMySQLConnection().catch(console.error); + diff --git a/test-websocket-methods.ts b/test-websocket-methods.ts new file mode 100644 index 0000000..73f221a --- /dev/null +++ b/test-websocket-methods.ts @@ -0,0 +1,366 @@ +/** + * WebSocket Gateway 각 메서드 개별 테스트 + * 실제 서버가 실행 중이어야 합니다 (npm run start:dev) + */ +import { io, Socket } from 'socket.io-client'; +import * as Y from 'yjs'; +import { config } from 'dotenv'; + +config(); + +const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'; +const TEST_TOKEN = process.env.TEST_TOKEN || ''; + +let client: Socket | null = null; + +// 유틸리티 함수 +function createClient(): Socket { + const client = io(`${SERVER_URL}/cardsets`, { + auth: { + token: TEST_TOKEN, + }, + transports: ['websocket'], + }); + + // 모든 이벤트 로깅 (디버깅용) + client.onAny((event, ...args) => { + console.log(` 📨 이벤트 수신: ${event}`, args.length > 0 ? JSON.stringify(args[0]) : ''); + }); + + // 에러 이벤트 핸들러 + client.on('error', (error) => { + console.error(` ❌ 에러 이벤트:`, error); + }); + + client.on('connect_error', (error) => { + console.error(` ❌ 연결 에러:`, error.message); + }); + + return client; +} + +function waitForConnection(client: Socket, timeout = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('연결 타임아웃')); + }, timeout); + + client.on('connect', () => { + clearTimeout(timer); + resolve(); + }); + + client.on('connect_error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function waitForEvent( + client: Socket, + event: string, + timeout = 5000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${event} 이벤트 타임아웃 (${timeout}ms)`)); + }, timeout); + + client.once(event, (data: T) => { + clearTimeout(timer); + resolve(data); + }); + }); +} + +// 테스트 1: join-cardset 메서드 +async function testJoinCardset() { + console.log('\n📋 테스트 1: join-cardset 메서드'); + console.log('=' .repeat(50)); + + const cardsetId = 'test-join-1'; + client = createClient(); + + try { + // 연결 + await waitForConnection(client); + console.log('✅ WebSocket 연결 성공'); + + // 에러 이벤트도 확인 + let errorReceived = false; + client.once('error', (error: any) => { + errorReceived = true; + console.error(` ❌ 서버에서 에러 수신:`, error); + if (error.details) { + console.error(` 📋 에러 상세: ${error.details}`); + } + }); + + // sync 이벤트 대기 + const syncPromise = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + 10000, // 타임아웃 10초로 증가 + ); + + // join-cardset 이벤트 전송 + console.log(`📤 join-cardset 전송: ${cardsetId}`); + console.log(` ⏳ sync 이벤트 대기 중... (최대 10초)`); + client.emit('join-cardset', { cardsetId }); + + // sync 이벤트 수신 + const syncData = await syncPromise; + console.log('✅ sync 이벤트 수신 성공'); + console.log(` cardsetId: ${syncData.cardsetId}`); + console.log(` update 길이: ${syncData.update.length} bytes`); + + // 문서 복원 검증 + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(syncData.update)); + console.log('✅ 문서 복원 성공'); + + client.disconnect(); + console.log('✅ 테스트 1 완료\n'); + return true; + } catch (error) { + console.error('❌ 테스트 1 실패:', error); + if (client) client.disconnect(); + return false; + } +} + +// 테스트 2: update 메서드 +async function testUpdate() { + console.log('\n📋 테스트 2: update 메서드'); + console.log('=' .repeat(50)); + + const cardsetId = 'test-update-1'; + client = createClient(); + + try { + // 연결 + await waitForConnection(client); + console.log('✅ WebSocket 연결 성공'); + + // 먼저 조인 + const initialSync = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + client.emit('join-cardset', { cardsetId }); + const initialData = await initialSync; + console.log('✅ 조인 완료'); + + // 문서 수정 + const doc = new Y.Doc(); + Y.applyUpdate(doc, new Uint8Array(initialData.update)); + const testMap = doc.getMap('test'); + testMap.set('key1', 'value1'); + testMap.set('key2', 'value2'); + + // 업데이트 생성 + const update = Y.encodeStateAsUpdate(doc); + const updateArray = Array.from(update); + + // 업데이트 sync 이벤트 대기 + const updateSync = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + + // update 이벤트 전송 + console.log(`📤 update 전송: ${cardsetId}`); + client.emit('update', { cardsetId, update: updateArray }); + + // 업데이트 sync 이벤트 수신 + const updateData = await updateSync; + console.log('✅ 업데이트 sync 이벤트 수신 성공'); + console.log(` update 길이: ${updateData.update.length} bytes`); + + // 업데이트 검증 + const updatedDoc = new Y.Doc(); + Y.applyUpdate(updatedDoc, new Uint8Array(updateData.update)); + const updatedMap = updatedDoc.getMap('test'); + console.log(` key1: ${updatedMap.get('key1')}`); + console.log(` key2: ${updatedMap.get('key2')}`); + + client.disconnect(); + console.log('✅ 테스트 2 완료\n'); + return true; + } catch (error) { + console.error('❌ 테스트 2 실패:', error); + if (client) client.disconnect(); + return false; + } +} + +// 테스트 3: leave-cardset 메서드 +async function testLeaveCardset() { + console.log('\n📋 테스트 3: leave-cardset 메서드'); + console.log('=' .repeat(50)); + + const cardsetId = 'test-leave-1'; + client = createClient(); + + try { + // 연결 + await waitForConnection(client); + console.log('✅ WebSocket 연결 성공'); + + // 먼저 조인 + const syncPromise = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + client.emit('join-cardset', { cardsetId }); + await syncPromise; + console.log('✅ 조인 완료'); + + // leave-cardset 이벤트 전송 + console.log(`📤 leave-cardset 전송: ${cardsetId}`); + client.emit('leave-cardset', { cardsetId }); + + // 잠시 대기 (서버 처리 시간) + await new Promise((resolve) => setTimeout(resolve, 500)); + + client.disconnect(); + console.log('✅ 테스트 3 완료\n'); + return true; + } catch (error) { + console.error('❌ 테스트 3 실패:', error); + if (client) client.disconnect(); + return false; + } +} + +// 테스트 4: update - 증분 업데이트 테스트 +async function testIncrementalUpdate() { + console.log('\n📋 테스트 4: 증분 업데이트 테스트'); + console.log('=' .repeat(50)); + + const cardsetId = 'test-incremental-1'; + client = createClient(); + + try { + // 연결 + await waitForConnection(client); + console.log('✅ WebSocket 연결 성공'); + + // 조인 + const initialSync = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + client.emit('join-cardset', { cardsetId }); + const initialData = await initialSync; + console.log('✅ 조인 완료'); + + // 첫 번째 업데이트 + const doc1 = new Y.Doc(); + Y.applyUpdate(doc1, new Uint8Array(initialData.update)); + const map1 = doc1.getMap('test'); + map1.set('step1', 'value1'); + + const update1 = Y.encodeStateAsUpdate(doc1); + const update1Array = Array.from(update1); + + const sync1 = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + client.emit('update', { cardsetId, update: update1Array }); + await sync1; + console.log('✅ 첫 번째 업데이트 완료'); + + // 두 번째 업데이트 (증분) + const doc2 = new Y.Doc(); + Y.applyUpdate(doc2, new Uint8Array(initialData.update)); + Y.applyUpdate(doc2, update1); + const map2 = doc2.getMap('test'); + map2.set('step2', 'value2'); + + const update2 = Y.encodeStateAsUpdate(doc2); + const update2Array = Array.from(update2); + + const sync2 = waitForEvent<{ cardsetId: string; update: number[] }>( + client, + 'sync', + ); + client.emit('update', { cardsetId, update: update2Array }); + const sync2Data = await sync2; + console.log('✅ 두 번째 업데이트 완료'); + + // 최종 상태 검증 + const finalDoc = new Y.Doc(); + Y.applyUpdate(finalDoc, new Uint8Array(sync2Data.update)); + const finalMap = finalDoc.getMap('test'); + console.log(` step1: ${finalMap.get('step1')}`); + console.log(` step2: ${finalMap.get('step2')}`); + + client.disconnect(); + console.log('✅ 테스트 4 완료\n'); + return true; + } catch (error) { + console.error('❌ 테스트 4 실패:', error); + if (client) client.disconnect(); + return false; + } +} + +// 메인 실행 함수 +async function main() { + console.log('🧪 WebSocket Gateway 메서드 개별 테스트 시작'); + console.log(`📋 서버 URL: ${SERVER_URL}\n`); + + if (!TEST_TOKEN) { + console.warn('⚠️ TEST_TOKEN이 설정되지 않았습니다.'); + console.warn(' (서버에서 SKIP_WS_AUTH=true로 설정되어 있으면 문제없습니다)\n'); + } + + console.log('💡 팁: 서버 터미널에서 다음 로그를 확인하세요:'); + console.log(' - "User test-user joining cardset ..."'); + console.log(' - "User test-user joined cardset ..."'); + console.log(' - 에러 메시지가 있다면 확인하세요\n'); + + const results = { + joinCardset: false, + update: false, + leaveCardset: false, + incrementalUpdate: false, + }; + + try { + results.joinCardset = await testJoinCardset(); + results.update = await testUpdate(); + results.leaveCardset = await testLeaveCardset(); + results.incrementalUpdate = await testIncrementalUpdate(); + + // 결과 요약 + console.log('\n' + '='.repeat(50)); + console.log('📊 테스트 결과 요약'); + console.log('='.repeat(50)); + console.log(`✅ join-cardset: ${results.joinCardset ? '통과' : '실패'}`); + console.log(`✅ update: ${results.update ? '통과' : '실패'}`); + console.log(`✅ leave-cardset: ${results.leaveCardset ? '통과' : '실패'}`); + console.log( + `✅ 증분 업데이트: ${results.incrementalUpdate ? '통과' : '실패'}`, + ); + console.log('='.repeat(50)); + + const allPassed = Object.values(results).every((r) => r === true); + if (allPassed) { + console.log('\n🎉 모든 테스트 통과!'); + process.exit(0); + } else { + console.log('\n❌ 일부 테스트 실패'); + process.exit(1); + } + } catch (error) { + console.error('\n❌ 테스트 실행 중 오류:', error); + process.exit(1); + } +} + +main().catch(console.error); + diff --git a/test-websocket-simple.ts b/test-websocket-simple.ts new file mode 100644 index 0000000..8a98ff5 --- /dev/null +++ b/test-websocket-simple.ts @@ -0,0 +1,108 @@ +/** + * 간단한 WebSocket 기능 검증 스크립트 + * 실제 서버 없이 코드 로직만 검증 + */ +import * as Y from 'yjs'; + +console.log('🧪 WebSocket 핸들러 로직 검증 시작...\n'); + +// 1. join-cardset 로직 검증 +console.log('1️⃣ join-cardset 로직 검증...'); +function testJoinCardset() { + const cardsetId = 'test-123'; + const doc = new Y.Doc(); + const testMap = doc.getMap('content'); + testMap.set('key1', 'value1'); + + // 문서 상태 인코딩 (실제 join-cardset에서 하는 작업) + const state = Y.encodeStateAsUpdate(doc); + const updateArray = Array.from(state); + + // 클라이언트가 받을 sync 이벤트 데이터 + const syncData = { + cardsetId, + update: updateArray, + }; + + console.log(' ✅ 문서 상태 인코딩 성공'); + console.log(` cardsetId: ${syncData.cardsetId}`); + console.log(` update 길이: ${syncData.update.length} bytes\n`); + + // 클라이언트가 받은 데이터로 문서 복원 + const clientDoc = new Y.Doc(); + Y.applyUpdate(clientDoc, new Uint8Array(syncData.update)); + const clientMap = clientDoc.getMap('content'); + console.log(` ✅ 클라이언트 문서 복원 성공`); + console.log(` key1: ${clientMap.get('key1')}\n`); + + return true; +} + +// 2. update 로직 검증 +console.log('2️⃣ update 로직 검증...'); +function testUpdate() { + const cardsetId = 'test-456'; + + // 서버의 문서 상태 + const serverDoc = new Y.Doc(); + const serverMap = serverDoc.getMap('content'); + serverMap.set('initial', 'value'); + + // 클라이언트가 보낼 업데이트 + const clientDoc = new Y.Doc(); + Y.applyUpdate(clientDoc, Y.encodeStateAsUpdate(serverDoc)); // 초기 상태 동기화 + const clientMap = clientDoc.getMap('content'); + clientMap.set('newKey', 'newValue'); // 수정 + + // 클라이언트가 보내는 증분 업데이트 + const incrementalUpdate = Y.encodeStateAsUpdate(clientDoc); + const updateArray = Array.from(incrementalUpdate); + + console.log(' ✅ 증분 업데이트 생성 성공'); + console.log(` update 길이: ${updateArray.length} bytes`); + + // 서버에서 업데이트 적용 + Y.applyUpdate(serverDoc, new Uint8Array(updateArray)); + console.log(` ✅ 서버 문서에 업데이트 적용 성공`); + console.log(` initial: ${serverMap.get('initial')}`); + console.log(` newKey: ${serverMap.get('newKey')}\n`); + + // 브로드캐스트할 전체 상태 + const broadcastState = Y.encodeStateAsUpdate(serverDoc); + console.log(` ✅ 브로드캐스트 상태 생성 성공`); + console.log(` broadcast 길이: ${Array.from(broadcastState).length} bytes\n`); + + return true; +} + +// 3. leave-cardset 로직 검증 +console.log('3️⃣ leave-cardset 로직 검증...'); +function testLeaveCardset() { + const cardsetId = 'test-789'; + console.log(` ✅ leave-cardset 이벤트 처리 준비 완료`); + console.log(` cardsetId: ${cardsetId}\n`); + return true; +} + +// 테스트 실행 +try { + const results = [ + testJoinCardset(), + testUpdate(), + testLeaveCardset(), + ]; + + if (results.every((r) => r === true)) { + console.log('🎉 모든 WebSocket 핸들러 로직 검증 통과!'); + console.log('\n📝 요약:'); + console.log(' ✅ join-cardset: 문서 로드 및 sync 이벤트 전송 로직 정상'); + console.log(' ✅ update: 증분 업데이트 적용 및 브로드캐스트 로직 정상'); + console.log(' ✅ leave-cardset: 이벤트 처리 준비 완료'); + console.log('\n⚠️ 주의: 실제 WebSocket 연결 테스트는 서버 실행 후'); + console.log(' "npm run test:websocket" 명령어를 사용하세요.'); + } +} catch (error) { + console.error('❌ 오류 발생:', error); + process.exit(1); +} + diff --git a/test-yjs-integration.ts b/test-yjs-integration.ts new file mode 100644 index 0000000..69d5b0f --- /dev/null +++ b/test-yjs-integration.ts @@ -0,0 +1,105 @@ +/** + * Yjs 문서 서비스 통합 테스트 + * 실제 Redis 연결이 필요합니다 + */ +import { config } from 'dotenv'; +import Redis from 'ioredis'; +import * as Y from 'yjs'; + +config(); + +async function testYjsIntegration() { + console.log('🧪 Yjs 통합 테스트 시작...\n'); + + const redisHost = process.env.REDIS_HOST || 'localhost'; + const redisPort = Number(process.env.REDIS_PORT) || 6379; + const redisPassword = process.env.REDIS_PASSWORD; + + const redisClient = new Redis({ + host: redisHost, + port: redisPort, + password: redisPassword, + retryStrategy: (times) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + + try { + // 1. 연결 테스트 + console.log('1️⃣ Redis 연결 테스트...'); + await redisClient.ping(); + console.log(' ✅ Redis 연결 성공!\n'); + + // 2. 문서 저장 및 로드 + console.log('2️⃣ 문서 저장 및 로드 테스트...'); + const cardsetId = 'test-integration-123'; + const doc = new Y.Doc(); + const testMap = doc.getMap('content'); + testMap.set('key1', 'value1'); + testMap.set('key2', 'value2'); + + const state = Y.encodeStateAsUpdate(doc); + const key = `yjs:cardset:${cardsetId}`; + await redisClient.set(key, Buffer.from(state)); + await redisClient.expire(key, 60); + + const loadedData = await redisClient.getBuffer(key); + if (loadedData) { + const loadedDoc = new Y.Doc(); + Y.applyUpdate(loadedDoc, loadedData); + const loadedMap = loadedDoc.getMap('content'); + console.log(` ✅ 문서 로드 성공!`); + console.log(` key1: ${loadedMap.get('key1')}`); + console.log(` key2: ${loadedMap.get('key2')}\n`); + } + + // 3. 증분 업데이트 리스트 테스트 + console.log('3️⃣ 증분 업데이트 리스트 테스트...'); + const historyKey = `yjs:cardset:${cardsetId}:updates`; + const update1 = Buffer.from([1, 2, 3]); + const update2 = Buffer.from([4, 5, 6]); + + await redisClient.rpush(historyKey, update1.toString('base64')); + await redisClient.rpush(historyKey, update2.toString('base64')); + + const updates = await redisClient.lrange(historyKey, 0, -1); + console.log(` ✅ 증분 업데이트 저장 성공! (${updates.length}개)\n`); + + // 4. 클라이언트 관리 테스트 + console.log('4️⃣ 클라이언트 관리 테스트...'); + const cardsetKey = `yjs:cardset:${cardsetId}:clients`; + const clientId = 'test-client-1'; + + await redisClient.sadd(cardsetKey, clientId); + const count = await redisClient.scard(cardsetKey); + console.log(` ✅ 클라이언트 등록 성공! (활성 클라이언트: ${count}명)\n`); + + await redisClient.srem(cardsetKey, clientId); + const countAfter = await redisClient.scard(cardsetKey); + console.log( + ` ✅ 클라이언트 해제 성공! (활성 클라이언트: ${countAfter}명)\n`, + ); + + // 5. 정리 + console.log('5️⃣ 테스트 데이터 정리...'); + await redisClient.del(key); + await redisClient.del(historyKey); + await redisClient.del(cardsetKey); + console.log(' ✅ 테스트 데이터 삭제 완료!\n'); + + console.log('🎉 모든 통합 테스트 통과!'); + } catch (error) { + console.error('❌ 오류 발생:', error); + if (error instanceof Error) { + console.error(` 메시지: ${error.message}`); + } + process.exit(1); + } finally { + redisClient.disconnect(); + console.log('\n🔌 Redis 연결 종료'); + } +} + +testYjsIntegration().catch(console.error); + From 1fa62ba8ab36c019ebd9b9922228df53ea37e63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 1 Dec 2025 17:35:14 +0900 Subject: [PATCH 14/35] =?UTF-8?q?Chore:=20=EB=B3=80=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index ab9b3d2..6b64b5f 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -150,13 +150,13 @@ export class CollaborationGateway @SubscribeMessage('awareness') // ← 클라이언트가 보낸 "awareness" 받음 handleAwareness( client: Socket, - payload: { documentId: string; awareness: Uint8Array }, + payload: { cardsetId: string; awareness: Uint8Array }, ) { - const { documentId, awareness } = payload; + const { cardsetId, awareness } = payload; // 같은 문서에 있는 클라이언트에 "awareness"로 브로드캐스트 - client.to(documentId).emit('awareness', { - data: { documentId, awareness: new Uint8Array(awareness) }, + client.to(cardsetId).emit('awareness', { + data: { cardsetId, awareness: new Uint8Array(awareness) }, }); } From a4c3945bbd758d403b8903998823a78675c05536 Mon Sep 17 00:00:00 2001 From: bebusl Date: Thu, 4 Dec 2025 01:28:02 +0900 Subject: [PATCH 15/35] =?UTF-8?q?fix:=20socket=20=EB=A9=94=EC=84=B8?= =?UTF-8?q?=EC=A7=80=20=ED=98=95=ED=83=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 6b64b5f..6a0f1dc 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -86,10 +86,14 @@ export class CollaborationGateway // 클라이언트에게 현재 카드셋 상태 전송 -> 직렬화 const state = Y.encodeStateAsUpdate(doc); - +const wrapper = { + cardsetId, + update:Array.from(state) +} +const blob = Buffer.from(JSON.stringify(wrapper)) client.emit('sync', { cardsetId, - update: Array.from(state), + blob }); this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); @@ -106,10 +110,12 @@ export class CollaborationGateway try { const emptyDoc = new Y.Doc(); const state = Y.encodeStateAsUpdate(emptyDoc); - client.emit('sync', { + + const wrapper = { cardsetId, - update: Array.from(state), - }); + update:Array.from(state)} + const blob = Buffer.from(JSON.stringify(wrapper)) + client.emit('sync',blob); this.logger.warn( `Sent empty document to client due to error for cardset ${cardsetId}`, ); @@ -196,10 +202,11 @@ export class CollaborationGateway // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 const state = Y.encodeStateAsUpdate(doc); - this.server.to(`cardset:${cardsetId}`).emit('sync', { + const wrapper = { cardsetId, - update: state, - }); + update:Array.from(state)} + const blob = Buffer.from(JSON.stringify((wrapper))) + this.server.to(`cardset:${cardsetId}`).emit('sync', blob); this.logger.log( `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, ); From ba8de461e1ed8023bd35d5afd9fc1227dae53192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 4 Dec 2025 17:22:58 +0900 Subject: [PATCH 16/35] =?UTF-8?q?Refactor:=20=EB=B8=8C=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EC=BA=90=EC=8A=A4=ED=8A=B8=EC=8B=9C=20mapper=EB=A1=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=9B=84=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 80 ++++++++++++++++++------------ 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 6a0f1dc..8411a54 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -84,17 +84,8 @@ export class CollaborationGateway doc = new Y.Doc(); } - // 클라이언트에게 현재 카드셋 상태 전송 -> 직렬화 - const state = Y.encodeStateAsUpdate(doc); -const wrapper = { - cardsetId, - update:Array.from(state) -} -const blob = Buffer.from(JSON.stringify(wrapper)) - client.emit('sync', { - cardsetId, - blob - }); + // 클라이언트에게 현재 카드셋 상태 전송 + this.sendSync(client, cardsetId, doc); this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); } catch (error) { @@ -109,13 +100,7 @@ const blob = Buffer.from(JSON.stringify(wrapper)) // 에러가 발생해도 빈 문서라도 보내서 클라이언트가 연결 유지할 수 있도록 try { const emptyDoc = new Y.Doc(); - const state = Y.encodeStateAsUpdate(emptyDoc); - - const wrapper = { - cardsetId, - update:Array.from(state)} - const blob = Buffer.from(JSON.stringify(wrapper)) - client.emit('sync',blob); + this.sendSync(client, cardsetId, emptyDoc); this.logger.warn( `Sent empty document to client due to error for cardset ${cardsetId}`, ); @@ -201,12 +186,7 @@ const blob = Buffer.from(JSON.stringify(wrapper)) await this.yjsDocumentService.saveUpdate(cardsetId, updateBuffer); // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 - const state = Y.encodeStateAsUpdate(doc); - const wrapper = { - cardsetId, - update:Array.from(state)} - const blob = Buffer.from(JSON.stringify((wrapper))) - this.server.to(`cardset:${cardsetId}`).emit('sync', blob); + this.broadcastSync(cardsetId, doc); this.logger.log( `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, ); @@ -216,6 +196,42 @@ const blob = Buffer.from(JSON.stringify(wrapper)) } } + /** + * Yjs 문서를 브로드캐스트용 Buffer로 변환 + * @param doc Yjs 문서 + * @param cardsetId 카드셋 ID + * @returns 브로드캐스트용 Buffer + */ + private createSyncBuffer(doc: Y.Doc, cardsetId: string): Buffer { + const state = Y.encodeStateAsUpdate(doc); + const wrapper = { + cardsetId, + update: Array.from(state), + }; + return Buffer.from(JSON.stringify(wrapper)); + } + + /** + * 카드셋 룸에 sync 이벤트 브로드캐스트 + * @param cardsetId 카드셋 ID + * @param doc Yjs 문서 + */ + private broadcastSync(cardsetId: string, doc: Y.Doc): void { + const blob = this.createSyncBuffer(doc, cardsetId); + this.server.to(`cardset:${cardsetId}`).emit('sync', blob); + } + + /** + * 클라이언트에게 sync 이벤트 전송 + * @param client Socket 클라이언트 + * @param cardsetId 카드셋 ID + * @param doc Yjs 문서 + */ + private sendSync(client: Socket, cardsetId: string, doc: Y.Doc): void { + const blob = this.createSyncBuffer(doc, cardsetId); + client.emit('sync', blob); + } + /** * DB에서 문서를 로드하거나 없으면 새로 생성 * DB에서 로드한 경우 Redis에 저장 @@ -229,13 +245,13 @@ const blob = Buffer.from(JSON.stringify(wrapper)) await this.cardsetService.loadCardsetContentFromDB(numericCardsetId); if (doc) { // DB에서 로드한 문서를 Redis에 저장 (실패해도 계속 진행) - await this.yjsDocumentService.saveDocument(cardsetId, doc).catch( - (error) => { + await this.yjsDocumentService + .saveDocument(cardsetId, doc) + .catch((error) => { this.logger.warn( `Failed to save document to Redis after DB load: ${error}`, ); - }, - ); + }); this.logger.log( `Loaded Yjs document from DB and saved to Redis for cardset ${cardsetId}`, ); @@ -258,13 +274,13 @@ const blob = Buffer.from(JSON.stringify(wrapper)) const doc = new Y.Doc(); this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); // Redis 저장 실패해도 문서는 반환 (메모리에서 사용 가능) - await this.yjsDocumentService.saveDocument(cardsetId, doc).catch( - (error) => { + await this.yjsDocumentService + .saveDocument(cardsetId, doc) + .catch((error) => { this.logger.warn( `Failed to save new document to Redis: ${error}, continuing anyway`, ); - }, - ); + }); return doc; } From cf22d468932ef6d0f1794428f9ce968158e96fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 4 Dec 2025 17:49:54 +0900 Subject: [PATCH 17/35] =?UTF-8?q?Refactor:=20awareness=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=98=95=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 108 +++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 8411a54..14ddccc 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -85,7 +85,7 @@ export class CollaborationGateway } // 클라이언트에게 현재 카드셋 상태 전송 - this.sendSync(client, cardsetId, doc); + this.sendSync(cardsetId, doc, client); this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); } catch (error) { @@ -100,13 +100,13 @@ export class CollaborationGateway // 에러가 발생해도 빈 문서라도 보내서 클라이언트가 연결 유지할 수 있도록 try { const emptyDoc = new Y.Doc(); - this.sendSync(client, cardsetId, emptyDoc); + this.sendSync(cardsetId, emptyDoc, client); this.logger.warn( `Sent empty document to client due to error for cardset ${cardsetId}`, ); } catch (fallbackError) { this.logger.error('Failed to send fallback document:', fallbackError); - client.emit('error', { + this.sendError(client, { message: 'Failed to join cardset', details: error instanceof Error ? error.message : String(error), }); @@ -146,9 +146,7 @@ export class CollaborationGateway const { cardsetId, awareness } = payload; // 같은 문서에 있는 클라이언트에 "awareness"로 브로드캐스트 - client.to(cardsetId).emit('awareness', { - data: { cardsetId, awareness: new Uint8Array(awareness) }, - }); + this.broadcastAwareness(cardsetId, awareness); } // Yjs 업데이트 (클라이언트가 변경사항을 받을 때) @@ -165,7 +163,7 @@ export class CollaborationGateway ); if (!update) { - client.emit('error', { message: 'Update data is required' }); + this.sendError(client, { message: 'Update data is required' }); return; } @@ -192,44 +190,114 @@ export class CollaborationGateway ); } catch (error) { this.logger.error('Error during sync:', error); - client.emit('error', { message: 'Sync failed' }); + this.sendError(client, { message: 'Sync failed' }); } } /** - * Yjs 문서를 브로드캐스트용 Buffer로 변환 + * Yjs 문서를 브로드캐스트용 Buffer로 변환 (최적화된 버전) + * - JSON.stringify를 한 번만 호출하여 성능 최적화 + * - Buffer.from()에 명시적 인코딩 지정으로 안정성 향상 + * - 중간 객체 생성 최소화 * @param doc Yjs 문서 * @param cardsetId 카드셋 ID * @returns 브로드캐스트용 Buffer */ private createSyncBuffer(doc: Y.Doc, cardsetId: string): Buffer { const state = Y.encodeStateAsUpdate(doc); - const wrapper = { + // JSON 구조를 직접 문자열로 구성하여 중간 객체 생성 최소화 + const jsonStr = JSON.stringify({ cardsetId, update: Array.from(state), - }; - return Buffer.from(JSON.stringify(wrapper)); + }); + return Buffer.from(jsonStr, 'utf8'); } /** - * 카드셋 룸에 sync 이벤트 브로드캐스트 + * sync 이벤트 전송 (통일된 인터페이스) * @param cardsetId 카드셋 ID * @param doc Yjs 문서 + * @param client 선택적 클라이언트 (없으면 룸 전체에 브로드캐스트) */ - private broadcastSync(cardsetId: string, doc: Y.Doc): void { + private sendSync(cardsetId: string, doc: Y.Doc, client?: Socket): void { const blob = this.createSyncBuffer(doc, cardsetId); - this.server.to(`cardset:${cardsetId}`).emit('sync', blob); + if (client) { + client.emit('sync', blob); + } else { + this.server.to(`cardset:${cardsetId}`).emit('sync', blob); + } } /** - * 클라이언트에게 sync 이벤트 전송 - * @param client Socket 클라이언트 + * 카드셋 룸에 sync 이벤트 브로드캐스트 (최적화된 버전) * @param cardsetId 카드셋 ID * @param doc Yjs 문서 */ - private sendSync(client: Socket, cardsetId: string, doc: Y.Doc): void { - const blob = this.createSyncBuffer(doc, cardsetId); - client.emit('sync', blob); + private broadcastSync(cardsetId: string, doc: Y.Doc): void { + this.sendSync(cardsetId, doc); + } + + /** + * 클라이언트에게 error 이벤트 전송 (최적화된 버전) + * @param client Socket 클라이언트 + * @param errorData 에러 데이터 + */ + private sendError( + client: Socket, + errorData: { message: string; details?: string }, + ): void { + // JSON 문자열을 직접 생성하여 중간 객체 생성 최소화 + const jsonStr = JSON.stringify(errorData); + const blob = Buffer.from(jsonStr, 'utf8'); + client.emit('error', blob); + } + + /** + * awareness 데이터를 Buffer로 변환 + * @param cardsetId 카드셋 ID + * @param awareness awareness 데이터 + * @returns Buffer + */ + private createAwarenessBuffer( + cardsetId: string, + awareness: Uint8Array, + ): Buffer { + // JSON 구조를 직접 문자열로 구성하여 중간 래퍼 객체 생성 최소화 + const jsonStr = JSON.stringify({ + data: { + cardsetId, + awareness: Array.from(awareness), + }, + }); + return Buffer.from(jsonStr, 'utf8'); + } + + /** + * awareness 이벤트 전송 (통일된 인터페이스) + * @param cardsetId 카드셋 ID + * @param awareness awareness 데이터 + * @param client 선택적 클라이언트 (없으면 룸 전체에 브로드캐스트) + */ + private sendAwareness( + cardsetId: string, + awareness: Uint8Array, + client?: Socket, + ): void { + const blob = this.createAwarenessBuffer(cardsetId, awareness); + if (client) { + client.emit('awareness', blob); + } else { + this.server.to(`cardset:${cardsetId}`).emit('awareness', blob); + } + } + + /** + * 카드셋 룸에 awareness 이벤트 브로드캐스트 (최적화된 버전) + * @param cardsetId 카드셋 ID + * @param awareness awareness 데이터 + */ + private broadcastAwareness(cardsetId: string, awareness: Uint8Array): void { + this.sendAwareness(cardsetId, awareness); } /** From 4c37d7651ee03e4309eb6e52468bee0566d473c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 15 Dec 2025 10:33:36 +0900 Subject: [PATCH 18/35] =?UTF-8?q?Add:=20=EC=A0=84=EC=97=AD=20prefix?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.controller.ts | 2 +- src/main.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index 6d6026b..87b527e 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -1,7 +1,7 @@ import { Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; import { CardsetService } from './cardset.service'; -@Controller('v1/card-sets') +@Controller('card-sets') export class CardsetController { constructor(private readonly cardsetService: CardsetService) {} diff --git a/src/main.ts b/src/main.ts index 6722a94..aafe44c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,9 @@ import { NestExpressApplication } from '@nestjs/platform-express'; async function bootstrap() { const app = await NestFactory.create(AppModule); + // 글로벌 API prefix 설정 + app.setGlobalPrefix('api/v1'); + // 정적 파일 서빙 설정 제거 (YJS 제거로 불필요) // Socket.IO 어댑터 설정 From 0b0f35fdc9763cf66c55c20770bf663ccd6f0334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 14:33:04 +0900 Subject: [PATCH 19/35] =?UTF-8?q?Refatctor:=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 4 ++- .../entities/cardset-manager.entity.ts | 6 ++--- src/cardset/cardset.service.ts | 27 ++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 05e5b40..abcc904 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,7 +31,9 @@ import { WebSocketModule } from './websocket/websocket.module'; CardSetManager, Card, ], - synchronize: false, + synchronize: process.env.DB_SYNCHRONIZE === 'true' || process.env.NODE_ENV !== 'production', + dropSchema: false, // 기존 스키마 보존 + migrationsRun: false, // 마이그레이션 자동 실행 비활성화 }), CardsetModule, CardsetManagerModule, diff --git a/src/cardset-manager/entities/cardset-manager.entity.ts b/src/cardset-manager/entities/cardset-manager.entity.ts index 823437b..5f4c10e 100644 --- a/src/cardset-manager/entities/cardset-manager.entity.ts +++ b/src/cardset-manager/entities/cardset-manager.entity.ts @@ -1,9 +1,9 @@ import { Entity, PrimaryGeneratedColumn, Column, Index, Unique } from 'typeorm'; @Entity('card_set_managers') -@Unique(['userId', 'cardSetId']) -@Index('idx_card_set_manager_user', ['userId']) -@Index('idx_card_set_manager_cardset', ['cardSetId']) +// @Unique('UQ_card_set_manager_user_cardset', ['userId', 'cardSetId']) +// @Index('idx_card_set_manager_user', ['userId']) +// @Index('idx_card_set_manager_cardset', ['cardSetId']) export class CardsetManager { @PrimaryGeneratedColumn({ type: 'int' }) id!: number; diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 71a4f95..8a317ed 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as Y from 'yjs'; @@ -8,6 +8,8 @@ import { YjsDocumentService } from '../websocket/yjs-document.service'; @Injectable() export class CardsetService { + private readonly logger = new Logger(CardsetService.name); + constructor( @InjectRepository(Cardset) private readonly cardsetRepository: Repository, @@ -32,15 +34,28 @@ export class CardsetService { }); if (!cardsetContent || !cardsetContent.content) { + this.logger.log( + `[loadCardsetContentFromDB] Cardset ${cardSetId}: No content found`, + ); return null; } + // 원본 JSON 문자열 로그 + this.logger.log( + `[loadCardsetContentFromDB] Cardset ${cardSetId} - Original content: ${cardsetContent.content.substring(0, 200)}${cardsetContent.content.length > 200 ? '...' : ''}`, + ); + // JSON 문자열을 파싱 const jsonContent = JSON.parse(cardsetContent.content) as Record< string, unknown >; + // 파싱된 JSON 내용 로그 + this.logger.log( + `[loadCardsetContentFromDB] Cardset ${cardSetId} - Parsed JSON: ${JSON.stringify(jsonContent, null, 2)}`, + ); + // Y.Doc 생성 및 JSON 데이터 적용 const doc = new Y.Doc(); if (jsonContent && typeof jsonContent === 'object') { @@ -50,12 +65,22 @@ export class CardsetService { const yMap = doc.getMap('content'); for (const [key, value] of Object.entries(jsonContent)) { yMap.set(key, value); + this.logger.debug( + `[loadCardsetContentFromDB] Cardset ${cardSetId} - Set Y.Map key: ${key}, value: ${JSON.stringify(value)}`, + ); } } + this.logger.log( + `[loadCardsetContentFromDB] Cardset ${cardSetId} - Successfully loaded and converted to Y.Doc`, + ); + return doc; } catch (error) { // 테이블이 없거나 조회 실패 시 null 반환 (에러 throw 안 함) + this.logger.error( + `[loadCardsetContentFromDB] Cardset ${cardSetId} - Error loading content: ${error instanceof Error ? error.message : String(error)}`, + ); return null; } } From 8cf1bdf6871f74f14c495f09188dca6134af3007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 15:17:28 +0900 Subject: [PATCH 20/35] =?UTF-8?q?Refactor:=20=EC=B9=B4=EB=93=9C=EC=85=8B?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=8B=9C=20=ED=98=95=ED=83=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.service.ts | 46 +++++++++++++++++++++++++-- src/main.ts | 5 +++ src/websocket/yjs-document.service.ts | 35 +++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 8a317ed..55482ee 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -95,14 +95,40 @@ export class CardsetService { } //레디스에서 카드셋 스냅샷 로드 + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Loading document from Redis...`, + ); const doc = await this.yjsDocumentService.loadDocument( cardSetId.toString(), ); if (!doc) { + this.logger.error( + `[saveCardsetContent] Cardset ${cardSetId} - Document not found in Redis`, + ); throw new NotFoundException('Cardset snapshot not found in Redis'); } - const jsonContent = JSON.stringify(doc.toJSON() ?? {}); + // Redis에서 로드한 Yjs 문서 내용 로그 + const docJson = doc; + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${JSON.stringify(docJson, null, 2)}`, + ); + + + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${Object.keys(docJson), Object.values(docJson)}`, + ); + + // Yjs 문서의 바이너리 상태도 로그 (디버깅용) + const stateUpdate = Y.encodeStateAsUpdate(doc); + this.logger.debug( + `[saveCardsetContent] Cardset ${cardSetId} - Yjs state update size: ${stateUpdate.length} bytes`, + ); + + const jsonContent = docJson ?? {}; + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${jsonContent} characters`, + ); //카드셋 내용 없으면 새로 생성 let cardsetContent = await this.cardsetContentRepository.findOne({ @@ -117,10 +143,26 @@ export class CardsetService { }); } - cardsetContent.content = jsonContent; + cardsetContent.content = Buffer.from(JSON.stringify(jsonContent)).toString("base64"); + + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${cardsetContent.content} characters`, + ); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Saving to database...`, + ); await this.cardsetContentRepository.save(cardsetContent); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Successfully saved to database`, + ); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Flushing incremental history...`, + ); await this.yjsDocumentService.flushIncrementalHistory(cardSetId.toString()); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Successfully flushed incremental history`, + ); } } diff --git a/src/main.ts b/src/main.ts index aafe44c..ef0709a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,11 @@ async function bootstrap() { // SwaggerModule.setup('api-docs', app, document); // } + app.enableCors({ + origin:true, + + }) + await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts index 5a5f747..6ec0562 100644 --- a/src/websocket/yjs-document.service.ts +++ b/src/websocket/yjs-document.service.ts @@ -106,15 +106,48 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { } const key = `yjs:cardset:${cardsetId}`; + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Loading from Redis key: ${key}`, + ); const data = await this.redisClient.getBuffer(key); if (!data) { + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - No data found in Redis`, + ); return null; } + // Redis에서 로드한 바이너리 데이터 정보 로그 + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Redis binary data size: ${data.length} bytes`, + ); + this.logger.debug( + `[loadDocument] Cardset ${cardsetId} - Redis binary data (first 100 bytes): ${Array.from( + data.slice(0, 100), + ) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' ')}`, + ); + const doc = new Y.Doc(); Y.applyUpdate(doc, data); - this.logger.debug(`Loaded Yjs document for cardset ${cardsetId}`); + + // Yjs 문서로 변환한 후의 내용 로그 + const docJson = doc; + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Yjs document content: ${JSON.stringify(docJson, null, 2)}`, + ); + + // Yjs 문서의 상태 업데이트 크기 로그 + const stateUpdate = Y.encodeStateAsUpdate(doc); + this.logger.debug( + `[loadDocument] Cardset ${cardsetId} - Yjs state update size: ${stateUpdate.length} bytes`, + ); + + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Successfully loaded Yjs document from Redis`, + ); return doc; } catch (error) { this.logger.error( From a4d62567428cbb58e15ed8ced4c554794c6cf474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 15:38:17 +0900 Subject: [PATCH 21/35] =?UTF-8?q?Fix:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.service.ts | 41 +++++++++++---------------- src/websocket/yjs-document.service.ts | 35 +---------------------- 2 files changed, 17 insertions(+), 59 deletions(-) diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 55482ee..1ba7e03 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -95,16 +95,10 @@ export class CardsetService { } //레디스에서 카드셋 스냅샷 로드 - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Loading document from Redis...`, - ); const doc = await this.yjsDocumentService.loadDocument( cardSetId.toString(), ); if (!doc) { - this.logger.error( - `[saveCardsetContent] Cardset ${cardSetId} - Document not found in Redis`, - ); throw new NotFoundException('Cardset snapshot not found in Redis'); } @@ -114,10 +108,19 @@ export class CardsetService { `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${JSON.stringify(docJson, null, 2)}`, ); + const cardsArray = doc.getArray('cards'); - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${Object.keys(docJson), Object.values(docJson)}`, - ); + cardsArray.forEach((cardMap) => { + //@ts-ignore + const questionText = cardMap!.get('question') as Y.Text; + //@ts-ignore + const answerText = cardMap!.get('answer') as Y.Text; + + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Card question: ${questionText.toString()}`, + `[saveCardsetContent] Cardset ${cardSetId} - Card answer: ${answerText.toString()}`, + ); + }); // Yjs 문서의 바이너리 상태도 로그 (디버깅용) const stateUpdate = Y.encodeStateAsUpdate(doc); @@ -125,10 +128,10 @@ export class CardsetService { `[saveCardsetContent] Cardset ${cardSetId} - Yjs state update size: ${stateUpdate.length} bytes`, ); - const jsonContent = docJson ?? {}; - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${jsonContent} characters`, - ); + const jsonContent = docJson; + // this.logger.log( + // `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${jsonContent.length} characters`, + // ); //카드셋 내용 없으면 새로 생성 let cardsetContent = await this.cardsetContentRepository.findOne({ @@ -149,20 +152,8 @@ export class CardsetService { `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${cardsetContent.content} characters`, ); - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Saving to database...`, - ); await this.cardsetContentRepository.save(cardsetContent); - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Successfully saved to database`, - ); - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Flushing incremental history...`, - ); await this.yjsDocumentService.flushIncrementalHistory(cardSetId.toString()); - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Successfully flushed incremental history`, - ); } } diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts index 6ec0562..5a5f747 100644 --- a/src/websocket/yjs-document.service.ts +++ b/src/websocket/yjs-document.service.ts @@ -106,48 +106,15 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { } const key = `yjs:cardset:${cardsetId}`; - this.logger.log( - `[loadDocument] Cardset ${cardsetId} - Loading from Redis key: ${key}`, - ); const data = await this.redisClient.getBuffer(key); if (!data) { - this.logger.log( - `[loadDocument] Cardset ${cardsetId} - No data found in Redis`, - ); return null; } - // Redis에서 로드한 바이너리 데이터 정보 로그 - this.logger.log( - `[loadDocument] Cardset ${cardsetId} - Redis binary data size: ${data.length} bytes`, - ); - this.logger.debug( - `[loadDocument] Cardset ${cardsetId} - Redis binary data (first 100 bytes): ${Array.from( - data.slice(0, 100), - ) - .map((b) => b.toString(16).padStart(2, '0')) - .join(' ')}`, - ); - const doc = new Y.Doc(); Y.applyUpdate(doc, data); - - // Yjs 문서로 변환한 후의 내용 로그 - const docJson = doc; - this.logger.log( - `[loadDocument] Cardset ${cardsetId} - Yjs document content: ${JSON.stringify(docJson, null, 2)}`, - ); - - // Yjs 문서의 상태 업데이트 크기 로그 - const stateUpdate = Y.encodeStateAsUpdate(doc); - this.logger.debug( - `[loadDocument] Cardset ${cardsetId} - Yjs state update size: ${stateUpdate.length} bytes`, - ); - - this.logger.log( - `[loadDocument] Cardset ${cardsetId} - Successfully loaded Yjs document from Redis`, - ); + this.logger.debug(`Loaded Yjs document for cardset ${cardsetId}`); return doc; } catch (error) { this.logger.error( From d8f6f305344505ce4f60e1b80eaf1521bc900061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 22:08:33 +0900 Subject: [PATCH 22/35] =?UTF-8?q?Fix:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=ED=98=95=ED=83=9C=EB=A1=9C=20MyS?= =?UTF-8?q?QL=EC=97=90=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.service.ts | 62 +++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 1ba7e03..22d4e96 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -22,6 +22,32 @@ export class CardsetService { return this.cardsetRepository.findOne({ where: { id } }); } + /** + * Yjs 배열에서 카드 데이터를 추출하여 객체 배열로 변환 + * @param cardsArray Yjs 배열 + * @returns 카드 객체 배열 [{question: string, answer: string}, ...] + */ + private extractCardsFromYjsArray( + cardsArray: Y.Array, + ): Array<{ question: string; answer: string }> { + return cardsArray.map((cardMap) => { + const questionText = (cardMap as Y.Map)?.get('question') as + | Y.Text + | undefined; + const answerText = (cardMap as Y.Map)?.get('answer') as + | Y.Text + | undefined; + + const question = questionText ? (questionText as unknown as string) : ''; + const answer = answerText ? (answerText as unknown as string) : ''; + + return { + question, + answer, + }; + }); + } + /** * DB에서 카드셋 내용을 로드하여 Y.Doc으로 변환 * @param cardSetId 카드셋 ID @@ -103,36 +129,21 @@ export class CardsetService { } // Redis에서 로드한 Yjs 문서 내용 로그 - const docJson = doc; this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${JSON.stringify(docJson, null, 2)}`, + `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${JSON.stringify(doc, null, 2)}`, ); const cardsArray = doc.getArray('cards'); - cardsArray.forEach((cardMap) => { - //@ts-ignore - const questionText = cardMap!.get('question') as Y.Text; - //@ts-ignore - const answerText = cardMap!.get('answer') as Y.Text; - - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Card question: ${questionText.toString()}`, - `[saveCardsetContent] Cardset ${cardSetId} - Card answer: ${answerText.toString()}`, - ); - }); + // 카드 배열을 객체 배열로 변환 + const cardsList = this.extractCardsFromYjsArray(cardsArray); - // Yjs 문서의 바이너리 상태도 로그 (디버깅용) - const stateUpdate = Y.encodeStateAsUpdate(doc); - this.logger.debug( - `[saveCardsetContent] Cardset ${cardSetId} - Yjs state update size: ${stateUpdate.length} bytes`, + // 배열을 문자열로 변환 + const cardsListString = JSON.stringify(cardsList); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Cards list: ${cardsListString}`, ); - const jsonContent = docJson; - // this.logger.log( - // `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${jsonContent.length} characters`, - // ); - //카드셋 내용 없으면 새로 생성 let cardsetContent = await this.cardsetContentRepository.findOne({ where: { cardset: { id: cardSetId } }, @@ -146,11 +157,8 @@ export class CardsetService { }); } - cardsetContent.content = Buffer.from(JSON.stringify(jsonContent)).toString("base64"); - - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Serialized JSON content length: ${cardsetContent.content} characters`, - ); + //카드셋 내용 저장 + cardsetContent.content = cardsListString; await this.cardsetContentRepository.save(cardsetContent); From 7af6cb04e72570e458462f56c53a88eaba02d01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 22:10:54 +0900 Subject: [PATCH 23/35] =?UTF-8?q?Chore:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 22d4e96..8eee70a 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -128,11 +128,6 @@ export class CardsetService { throw new NotFoundException('Cardset snapshot not found in Redis'); } - // Redis에서 로드한 Yjs 문서 내용 로그 - this.logger.log( - `[saveCardsetContent] Cardset ${cardSetId} - Redis document content: ${JSON.stringify(doc, null, 2)}`, - ); - const cardsArray = doc.getArray('cards'); // 카드 배열을 객체 배열로 변환 From e22a109b9b373b97acb9f57fc1a6698bdc4dd600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 17 Dec 2025 22:28:42 +0900 Subject: [PATCH 24/35] =?UTF-8?q?Feat:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EC=95=94=EA=B8=B0=EB=AA=A8=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.controller.ts | 5 +- src/cardset/cardset.controller.ts | 11 +++- src/cardset/cardset.service.ts | 53 ++++++++++++++++++- .../interfaces/api-response.interface.ts | 10 ++++ src/common/utils/response.util.ts | 42 +++++++++++++++ src/websocket/yjs-document.service.ts | 35 +++++++++++- 6 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 src/common/interfaces/api-response.interface.ts create mode 100644 src/common/utils/response.util.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 09e69fc..931424e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -2,12 +2,13 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import type { UserAuth } from '../types/userAuth.type'; import { AuthUser } from '../decorators/auth-user.decorator'; import { AuthGuard } from '../auth.guard'; +import { successResponse } from '../common/utils/response.util'; @Controller('auth') export class AuthController { @UseGuards(AuthGuard) @Get('test') - test(@AuthUser() userAuth: UserAuth): UserAuth { - return userAuth; + test(@AuthUser() userAuth: UserAuth) { + return successResponse(userAuth); } } diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index 87b527e..cf80cfe 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; import { CardsetService } from './cardset.service'; +import { successResponse } from '../common/utils/response.util'; @Controller('card-sets') export class CardsetController { @@ -10,6 +11,12 @@ export class CardsetController { @Param('cardSetId', ParseIntPipe) cardSetId: number, ) { await this.cardsetService.saveCardsetContent(cardSetId); - return { success: true }; + return successResponse({ success: true }, 200, '저장을 성공했습니다'); + } + + @Get(':cardSetId/cards') + async getCardsetInCards(@Param('cardSetId', ParseIntPipe) cardSetId: number) { + const cards = await this.cardsetService.getCardsetInCards(cardSetId); + return successResponse(cards, 200, '조회를 성공했습니다'); } } diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 8eee70a..60ef3a9 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as Y from 'yjs'; +import { randomUUID } from 'crypto'; import { Cardset } from './entities/cardset.entity'; import { CardsetContent } from './entities/cardset-content.entity'; import { YjsDocumentService } from '../websocket/yjs-document.service'; @@ -25,11 +26,11 @@ export class CardsetService { /** * Yjs 배열에서 카드 데이터를 추출하여 객체 배열로 변환 * @param cardsArray Yjs 배열 - * @returns 카드 객체 배열 [{question: string, answer: string}, ...] + * @returns 카드 객체 배열 [{id: string, question: string, answer: string}, ...] */ private extractCardsFromYjsArray( cardsArray: Y.Array, - ): Array<{ question: string; answer: string }> { + ): Array<{ id: string; question: string; answer: string }> { return cardsArray.map((cardMap) => { const questionText = (cardMap as Y.Map)?.get('question') as | Y.Text @@ -42,6 +43,7 @@ export class CardsetService { const answer = answerText ? (answerText as unknown as string) : ''; return { + id: randomUUID(), question, answer, }; @@ -159,4 +161,51 @@ export class CardsetService { await this.yjsDocumentService.flushIncrementalHistory(cardSetId.toString()); } + + /** + * 카드셋의 카드 목록을 조회 + * @param cardSetId 카드셋 ID + * @returns 카드 객체 배열 [{id: string, question: string, answer: string}, ...] + */ + async getCardsetInCards( + cardSetId: number, + ): Promise> { + const cardsetContent = await this.cardsetContentRepository.findOne({ + where: { cardset: { id: cardSetId } }, + }); + + if (!cardsetContent || !cardsetContent.content) { + this.logger.log( + `[getCardsetInCards] Cardset ${cardSetId}: No content found, returning empty array`, + ); + return []; + } + + try { + // JSON 문자열을 파싱하여 배열로 변환 + const cardsList = JSON.parse(cardsetContent.content) as Array<{ + id?: string; + question: string; + answer: string; + }>; + + // id가 없는 카드에 UUID 추가 + const cardsWithId = cardsList.map((card) => ({ + id: card.id || randomUUID(), + question: card.question, + answer: card.answer, + })); + + this.logger.log( + `[getCardsetInCards] Cardset ${cardSetId} - Found ${cardsWithId.length} cards`, + ); + + return cardsWithId; + } catch (error) { + this.logger.error( + `[getCardsetInCards] Cardset ${cardSetId} - Error parsing content: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } } diff --git a/src/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts new file mode 100644 index 0000000..1e78119 --- /dev/null +++ b/src/common/interfaces/api-response.interface.ts @@ -0,0 +1,10 @@ +/** + * 공통 API 응답 인터페이스 + */ +export interface ApiResponse { + status: number; + code: string | null; + message: string | null; + data: T; +} + diff --git a/src/common/utils/response.util.ts b/src/common/utils/response.util.ts new file mode 100644 index 0000000..4be9fe3 --- /dev/null +++ b/src/common/utils/response.util.ts @@ -0,0 +1,42 @@ +import { ApiResponse } from '../interfaces/api-response.interface'; + +/** + * 성공 응답 생성 헬퍼 함수 + * @param data 응답 데이터 + * @param status HTTP 상태 코드 (기본값: 200) + * @param message 응답 메시지 (기본값: null) + * @returns ApiResponse 객체 + */ +export function successResponse( + data: T, + status = 200, + message: string | null = null, +): ApiResponse { + return { + status, + code: null, + message, + data, + }; +} + +/** + * 에러 응답 생성 헬퍼 함수 + * @param code 에러 코드 + * @param message 에러 메시지 + * @param status HTTP 상태 코드 (기본값: 400) + * @returns ApiResponse 객체 + */ +export function errorResponse( + code: string, + message: string, + status = 400, +): ApiResponse { + return { + status, + code, + message, + data: null, + }; +} + diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts index 5a5f747..6ec0562 100644 --- a/src/websocket/yjs-document.service.ts +++ b/src/websocket/yjs-document.service.ts @@ -106,15 +106,48 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { } const key = `yjs:cardset:${cardsetId}`; + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Loading from Redis key: ${key}`, + ); const data = await this.redisClient.getBuffer(key); if (!data) { + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - No data found in Redis`, + ); return null; } + // Redis에서 로드한 바이너리 데이터 정보 로그 + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Redis binary data size: ${data.length} bytes`, + ); + this.logger.debug( + `[loadDocument] Cardset ${cardsetId} - Redis binary data (first 100 bytes): ${Array.from( + data.slice(0, 100), + ) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' ')}`, + ); + const doc = new Y.Doc(); Y.applyUpdate(doc, data); - this.logger.debug(`Loaded Yjs document for cardset ${cardsetId}`); + + // Yjs 문서로 변환한 후의 내용 로그 + const docJson = doc; + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Yjs document content: ${JSON.stringify(docJson, null, 2)}`, + ); + + // Yjs 문서의 상태 업데이트 크기 로그 + const stateUpdate = Y.encodeStateAsUpdate(doc); + this.logger.debug( + `[loadDocument] Cardset ${cardsetId} - Yjs state update size: ${stateUpdate.length} bytes`, + ); + + this.logger.log( + `[loadDocument] Cardset ${cardsetId} - Successfully loaded Yjs document from Redis`, + ); return doc; } catch (error) { this.logger.error( From ddb41a33937f5fc1b9b561388522d555c75a0aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Sat, 20 Dec 2025 11:26:27 +0900 Subject: [PATCH 25/35] =?UTF-8?q?Fix:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EB=A7=A4=EB=8B=88=EC=A0=80=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset-manager/entities/cardset-manager.entity.ts | 5 +---- src/common/interfaces/api-response.interface.ts | 2 ++ src/common/utils/response.util.ts | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cardset-manager/entities/cardset-manager.entity.ts b/src/cardset-manager/entities/cardset-manager.entity.ts index 5f4c10e..0abb3a6 100644 --- a/src/cardset-manager/entities/cardset-manager.entity.ts +++ b/src/cardset-manager/entities/cardset-manager.entity.ts @@ -1,9 +1,6 @@ -import { Entity, PrimaryGeneratedColumn, Column, Index, Unique } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity('card_set_managers') -// @Unique('UQ_card_set_manager_user_cardset', ['userId', 'cardSetId']) -// @Index('idx_card_set_manager_user', ['userId']) -// @Index('idx_card_set_manager_cardset', ['cardSetId']) export class CardsetManager { @PrimaryGeneratedColumn({ type: 'int' }) id!: number; diff --git a/src/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts index 1e78119..94be698 100644 --- a/src/common/interfaces/api-response.interface.ts +++ b/src/common/interfaces/api-response.interface.ts @@ -8,3 +8,5 @@ export interface ApiResponse { data: T; } + + diff --git a/src/common/utils/response.util.ts b/src/common/utils/response.util.ts index 4be9fe3..1ff5d6a 100644 --- a/src/common/utils/response.util.ts +++ b/src/common/utils/response.util.ts @@ -40,3 +40,5 @@ export function errorResponse( }; } + + From 88a0eff8b4c06d4833a5d96dd5a1730e93509a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Sat, 20 Dec 2025 11:34:04 +0900 Subject: [PATCH 26/35] =?UTF-8?q?Fix:=20lint=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 4 +- .../interfaces/api-response.interface.ts | 3 - src/common/utils/response.util.ts | 3 - src/main.ts | 5 +- .../websocket.gateway.integration.spec.ts | 75 +++++++++++-------- src/websocket/yjs-document.service.spec.ts | 6 +- 6 files changed, 51 insertions(+), 45 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index abcc904..d0a4ecd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,7 +31,9 @@ import { WebSocketModule } from './websocket/websocket.module'; CardSetManager, Card, ], - synchronize: process.env.DB_SYNCHRONIZE === 'true' || process.env.NODE_ENV !== 'production', + synchronize: + process.env.DB_SYNCHRONIZE === 'true' || + process.env.NODE_ENV !== 'production', dropSchema: false, // 기존 스키마 보존 migrationsRun: false, // 마이그레이션 자동 실행 비활성화 }), diff --git a/src/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts index 94be698..65f82fd 100644 --- a/src/common/interfaces/api-response.interface.ts +++ b/src/common/interfaces/api-response.interface.ts @@ -7,6 +7,3 @@ export interface ApiResponse { message: string | null; data: T; } - - - diff --git a/src/common/utils/response.util.ts b/src/common/utils/response.util.ts index 1ff5d6a..2f8629c 100644 --- a/src/common/utils/response.util.ts +++ b/src/common/utils/response.util.ts @@ -39,6 +39,3 @@ export function errorResponse( data: null, }; } - - - diff --git a/src/main.ts b/src/main.ts index ef0709a..ed466e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,9 +36,8 @@ async function bootstrap() { // } app.enableCors({ - origin:true, - - }) + origin: true, + }); await app.listen(process.env.PORT ?? 3000); } diff --git a/src/websocket/websocket.gateway.integration.spec.ts b/src/websocket/websocket.gateway.integration.spec.ts index 85ca767..ec2d6e4 100644 --- a/src/websocket/websocket.gateway.integration.spec.ts +++ b/src/websocket/websocket.gateway.integration.spec.ts @@ -9,6 +9,7 @@ import { Cardset } from '../cardset/entities/cardset.entity'; import { CardsetContent } from '../cardset/entities/cardset-content.entity'; import { Server, Socket } from 'socket.io'; import * as Y from 'yjs'; +import type { UserAuth } from '../types/userAuth.type'; describe('CollaborationGateway Integration', () => { let gateway: CollaborationGateway; @@ -88,7 +89,11 @@ describe('CollaborationGateway Integration', () => { describe('join-cardset flow', () => { it('should load document from Redis when available', async () => { const cardsetId = 'test-join-1'; - const user = { userId: 'user-1' }; + const user: UserAuth = { + userId: 'user-1', + role: 'user', + tokenVersion: 1, + }; // Redis에 문서 저장 const doc = new Y.Doc(); @@ -97,11 +102,9 @@ describe('CollaborationGateway Integration', () => { await yjsDocumentService.saveDocument(cardsetId, doc); // join-cardset 호출 - await gateway.handleJoinCardset( - user as any, - mockSocket as Socket, - { cardsetId }, - ); + await gateway.handleJoinCardset(user, mockSocket as Socket, { + cardsetId, + }); expect(mockSocket.join).toHaveBeenCalledWith(`cardset:${cardsetId}`); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); @@ -113,25 +116,25 @@ describe('CollaborationGateway Integration', () => { it('should load from DB when Redis is empty', async () => { const cardsetId = 'test-join-2'; const numericId = 2; - const user = { userId: 'user-2' }; + const user: UserAuth = { + userId: 'user-2', + role: 'user', + tokenVersion: 1, + }; // DB에서 로드하는 메서드 모킹 const dbDoc = new Y.Doc(); const dbArray = dbDoc.getArray('db-data'); dbArray.push(['from-db']); - jest + const loadFromDBSpy = jest .spyOn(cardsetService, 'loadCardsetContentFromDB') .mockResolvedValue(dbDoc); - await gateway.handleJoinCardset( - user as any, - mockSocket as Socket, - { cardsetId }, - ); + await gateway.handleJoinCardset(user, mockSocket as Socket, { + cardsetId, + }); - expect(cardsetService.loadCardsetContentFromDB).toHaveBeenCalledWith( - numericId, - ); + expect(loadFromDBSpy).toHaveBeenCalledWith(numericId); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); // 정리 @@ -140,18 +143,20 @@ describe('CollaborationGateway Integration', () => { it('should create new document when both Redis and DB are empty', async () => { const cardsetId = 'test-join-3'; - const user = { userId: 'user-3' }; + const user: UserAuth = { + userId: 'user-3', + role: 'user', + tokenVersion: 1, + }; // DB에서도 null 반환 jest .spyOn(cardsetService, 'loadCardsetContentFromDB') .mockResolvedValue(null); - await gateway.handleJoinCardset( - user as any, - mockSocket as Socket, - { cardsetId }, - ); + await gateway.handleJoinCardset(user, mockSocket as Socket, { + cardsetId, + }); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); @@ -163,7 +168,11 @@ describe('CollaborationGateway Integration', () => { describe('update flow', () => { it('should apply update and broadcast to all clients', async () => { const cardsetId = 'test-update-1'; - const user = { userId: 'user-4' }; + const user: UserAuth = { + userId: 'user-4', + role: 'user', + tokenVersion: 1, + }; // 문서 생성 const doc = new Y.Doc(); @@ -173,21 +182,21 @@ describe('CollaborationGateway Integration', () => { const update = new Uint8Array([1, 2, 3, 4, 5]); const updateArray = Array.from(update); - await gateway.handleUpdate( - user as any, - mockSocket as Socket, - { cardsetId, update: updateArray }, - ); + const mockEmit = jest.fn(); + (mockServer.to as jest.Mock).mockReturnValue({ + emit: mockEmit, + }); + + await gateway.handleUpdate(user, mockSocket as Socket, { + cardsetId, + update: updateArray, + }); expect(mockServer.to).toHaveBeenCalledWith(`cardset:${cardsetId}`); - expect(mockServer.to().emit).toHaveBeenCalledWith( - 'sync', - expect.any(Object), - ); + expect(mockEmit).toHaveBeenCalledWith('sync', expect.any(Object)); // 정리 await yjsDocumentService.deleteDocument(cardsetId); }); }); }); - diff --git a/src/websocket/yjs-document.service.spec.ts b/src/websocket/yjs-document.service.spec.ts index 6329865..79074c2 100644 --- a/src/websocket/yjs-document.service.spec.ts +++ b/src/websocket/yjs-document.service.spec.ts @@ -6,7 +6,10 @@ import { CardsetIncremental } from '../cardset/entities/cardset-incremental.enti describe('YjsDocumentService', () => { let service: YjsDocumentService; - let repositoryMock: any; + let repositoryMock: { + create: jest.Mock; + save: jest.Mock; + }; beforeEach(async () => { repositoryMock = { @@ -45,4 +48,3 @@ describe('YjsDocumentService', () => { // 실제 Redis 연결이 필요한 테스트는 test-yjs-integration.ts에서 실행 // Jest 유닛 테스트는 서비스 구조만 확인 }); - From f107ac5968aa0f285de2c71b03e3c3226f47e03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Sat, 20 Dec 2025 11:41:11 +0900 Subject: [PATCH 27/35] =?UTF-8?q?Fix:=20test=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.service.ts | 2 +- src/card/card.controller.spec.ts | 14 ++- src/card/card.service.spec.ts | 14 ++- src/cardset/cardset.controller.spec.ts | 40 +++++++- .../websocket.gateway.integration.spec.ts | 95 ++++++++++++++----- 5 files changed, 139 insertions(+), 26 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 736e443..ada6a0c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,6 @@ import * as jwt from 'jsonwebtoken'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import authConfig from 'src/config/authConfig'; +import authConfig from '../config/authConfig'; import type { ConfigType } from '@nestjs/config'; import { UserAuth } from '../types/userAuth.type'; diff --git a/src/card/card.controller.spec.ts b/src/card/card.controller.spec.ts index bf807c6..7ec34e6 100644 --- a/src/card/card.controller.spec.ts +++ b/src/card/card.controller.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { CardController } from './card.controller'; import { CardService } from './card.service'; +import { Card } from './entities/card.entity'; describe('CardController', () => { let controller: CardController; @@ -8,7 +10,17 @@ describe('CardController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [CardController], - providers: [CardService], + providers: [ + CardService, + { + provide: getRepositoryToken(Card), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(CardController); diff --git a/src/card/card.service.spec.ts b/src/card/card.service.spec.ts index 782eb09..d451079 100644 --- a/src/card/card.service.spec.ts +++ b/src/card/card.service.spec.ts @@ -1,12 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { CardService } from './card.service'; +import { Card } from './entities/card.entity'; describe('CardService', () => { let service: CardService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [CardService], + providers: [ + CardService, + { + provide: getRepositoryToken(Card), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, + ], }).compile(); service = module.get(CardService); diff --git a/src/cardset/cardset.controller.spec.ts b/src/cardset/cardset.controller.spec.ts index 74b9be8..79f1193 100644 --- a/src/cardset/cardset.controller.spec.ts +++ b/src/cardset/cardset.controller.spec.ts @@ -1,6 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; import { CardsetController } from './cardset.controller'; import { CardsetService } from './cardset.service'; +import { Cardset } from './entities/cardset.entity'; +import { CardsetContent } from './entities/cardset-content.entity'; +import { YjsDocumentService } from '../websocket/yjs-document.service'; +import { CardsetIncremental } from './entities/cardset-incremental.entity'; describe('CardsetController', () => { let controller: CardsetController; @@ -8,7 +14,39 @@ describe('CardsetController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [CardsetController], - providers: [CardsetService], + providers: [ + CardsetService, + YjsDocumentService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Cardset), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, + { + provide: getRepositoryToken(CardsetContent), + useValue: { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }, + }, + { + provide: getRepositoryToken(CardsetIncremental), + useValue: { + create: jest.fn(), + save: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(CardsetController); diff --git a/src/websocket/websocket.gateway.integration.spec.ts b/src/websocket/websocket.gateway.integration.spec.ts index ec2d6e4..1f0b045 100644 --- a/src/websocket/websocket.gateway.integration.spec.ts +++ b/src/websocket/websocket.gateway.integration.spec.ts @@ -4,12 +4,15 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { CollaborationGateway } from './websocket.gateway'; import { YjsDocumentService } from './yjs-document.service'; import { CardsetService } from '../cardset/cardset.service'; +import { AuthService } from '../auth/auth.service'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; import { CardsetIncremental } from '../cardset/entities/cardset-incremental.entity'; import { Cardset } from '../cardset/entities/cardset.entity'; import { CardsetContent } from '../cardset/entities/cardset-content.entity'; import { Server, Socket } from 'socket.io'; import * as Y from 'yjs'; import type { UserAuth } from '../types/userAuth.type'; +import authConfig from '../config/authConfig'; describe('CollaborationGateway Integration', () => { let gateway: CollaborationGateway; @@ -17,6 +20,19 @@ describe('CollaborationGateway Integration', () => { let cardsetService: CardsetService; let mockSocket: Partial; let mockServer: Partial; + let mockRedisClient: { + getBuffer: jest.Mock; + set: jest.Mock; + del: jest.Mock; + expire: jest.Mock; + rpush: jest.Mock; + lrange: jest.Mock; + sadd: jest.Mock; + srem: jest.Mock; + scard: jest.Mock; + smembers: jest.Mock; + on: jest.Mock; + }; beforeEach(async () => { mockSocket = { @@ -33,11 +49,27 @@ describe('CollaborationGateway Integration', () => { }), }; + mockRedisClient = { + getBuffer: jest.fn(), + set: jest.fn(), + del: jest.fn(), + expire: jest.fn(), + rpush: jest.fn(), + lrange: jest.fn(), + sadd: jest.fn(), + srem: jest.fn(), + scard: jest.fn(), + smembers: jest.fn(), + on: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ CollaborationGateway, YjsDocumentService, CardsetService, + AuthService, + WsAuthGuard, { provide: ConfigService, useValue: { @@ -49,6 +81,12 @@ describe('CollaborationGateway Integration', () => { }), }, }, + { + provide: authConfig.KEY, + useValue: { + jwtSecret: 'test-secret', + }, + }, { provide: getRepositoryToken(CardsetIncremental), useValue: { @@ -78,6 +116,10 @@ describe('CollaborationGateway Integration', () => { cardsetService = module.get(CardsetService); gateway.server = mockServer as Server; + + // Redis 클라이언트 모킹 + (yjsDocumentService as unknown as { redisClient: unknown }).redisClient = + mockRedisClient; }); it('should be defined', () => { @@ -88,18 +130,21 @@ describe('CollaborationGateway Integration', () => { describe('join-cardset flow', () => { it('should load document from Redis when available', async () => { - const cardsetId = 'test-join-1'; + const cardsetId = '1'; const user: UserAuth = { userId: 'user-1', role: 'user', tokenVersion: 1, }; - // Redis에 문서 저장 + // Redis에 문서 저장 모킹 const doc = new Y.Doc(); const testArray = doc.getArray('test'); testArray.push(['data1']); - await yjsDocumentService.saveDocument(cardsetId, doc); + const state = Y.encodeStateAsUpdate(doc); + mockRedisClient.getBuffer.mockResolvedValue(Buffer.from(state)); + mockRedisClient.set.mockResolvedValue('OK'); + mockRedisClient.expire.mockResolvedValue(1); // join-cardset 호출 await gateway.handleJoinCardset(user, mockSocket as Socket, { @@ -108,13 +153,10 @@ describe('CollaborationGateway Integration', () => { expect(mockSocket.join).toHaveBeenCalledWith(`cardset:${cardsetId}`); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); - - // 정리 - await yjsDocumentService.deleteDocument(cardsetId); }); it('should load from DB when Redis is empty', async () => { - const cardsetId = 'test-join-2'; + const cardsetId = '2'; const numericId = 2; const user: UserAuth = { userId: 'user-2', @@ -122,6 +164,9 @@ describe('CollaborationGateway Integration', () => { tokenVersion: 1, }; + // Redis가 비어있음을 모킹 + mockRedisClient.getBuffer.mockResolvedValue(null); + // DB에서 로드하는 메서드 모킹 const dbDoc = new Y.Doc(); const dbArray = dbDoc.getArray('db-data'); @@ -136,19 +181,19 @@ describe('CollaborationGateway Integration', () => { expect(loadFromDBSpy).toHaveBeenCalledWith(numericId); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); - - // 정리 - await yjsDocumentService.deleteDocument(cardsetId); }); it('should create new document when both Redis and DB are empty', async () => { - const cardsetId = 'test-join-3'; + const cardsetId = '3'; const user: UserAuth = { userId: 'user-3', role: 'user', tokenVersion: 1, }; + // Redis가 비어있음을 모킹 + mockRedisClient.getBuffer.mockResolvedValue(null); + // DB에서도 null 반환 jest .spyOn(cardsetService, 'loadCardsetContentFromDB') @@ -159,27 +204,36 @@ describe('CollaborationGateway Integration', () => { }); expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); - - // 정리 - await yjsDocumentService.deleteDocument(cardsetId); }); }); describe('update flow', () => { it('should apply update and broadcast to all clients', async () => { - const cardsetId = 'test-update-1'; + const cardsetId = '1'; const user: UserAuth = { userId: 'user-4', role: 'user', tokenVersion: 1, }; - // 문서 생성 + // 문서 생성 및 유효한 업데이트 생성 const doc = new Y.Doc(); - await yjsDocumentService.saveDocument(cardsetId, doc); + const testArray = doc.getArray('test'); + testArray.push(['initial']); + const initialState = Y.encodeStateAsUpdate(doc); + + // 업데이트 적용 + const updateDoc = new Y.Doc(); + Y.applyUpdate(updateDoc, initialState); + updateDoc.getArray('test').push(['new-item']); + const update = Y.encodeStateAsUpdate(updateDoc); + + // Redis 모킹 + mockRedisClient.getBuffer.mockResolvedValue(Buffer.from(initialState)); + mockRedisClient.set.mockResolvedValue('OK'); + mockRedisClient.expire.mockResolvedValue(1); + mockRedisClient.rpush.mockResolvedValue(1); - // 업데이트 데이터 - const update = new Uint8Array([1, 2, 3, 4, 5]); const updateArray = Array.from(update); const mockEmit = jest.fn(); @@ -194,9 +248,6 @@ describe('CollaborationGateway Integration', () => { expect(mockServer.to).toHaveBeenCalledWith(`cardset:${cardsetId}`); expect(mockEmit).toHaveBeenCalledWith('sync', expect.any(Object)); - - // 정리 - await yjsDocumentService.deleteDocument(cardsetId); }); }); }); From c665832d33167cf52b1ca1f38fd4b41507ca5b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 25 Dec 2025 00:07:39 +0900 Subject: [PATCH 28/35] =?UTF-8?q?Delete:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-mysql.ts | 162 ----------------- test-redis.ts | 82 --------- test-websocket-methods.ts | 366 -------------------------------------- test-websocket-simple.ts | 108 ----------- test-yjs-integration.ts | 105 ----------- 5 files changed, 823 deletions(-) delete mode 100644 test-mysql.ts delete mode 100644 test-redis.ts delete mode 100644 test-websocket-methods.ts delete mode 100644 test-websocket-simple.ts delete mode 100644 test-yjs-integration.ts diff --git a/test-mysql.ts b/test-mysql.ts deleted file mode 100644 index 22baad1..0000000 --- a/test-mysql.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * MySQL 연결 테스트 - */ -import { config } from 'dotenv'; -import { createConnection, Connection } from 'typeorm'; -import { Cardset } from './src/cardset/entities/cardset.entity'; -import { CardsetContent } from './src/cardset/entities/cardset-content.entity'; -import { CardsetIncremental } from './src/cardset/entities/cardset-incremental.entity'; -import { CardsetSnapshot } from './src/cardset/entities/cardset-snapshot.entity'; - -config(); - -async function testMySQLConnection() { - console.log('🧪 MySQL 연결 테스트 시작...\n'); - - const dbHost = process.env.DB_HOST || 'localhost'; - const dbPort = Number(process.env.DB_PORT) || 3306; - const dbUsername = process.env.DB_USERNAME || 'root'; - const dbPassword = process.env.DB_PASSWORD || ''; - const dbDatabase = process.env.DB_DATABASE || 'flipnote'; - - console.log('📋 설정 정보:'); - console.log(` Host: ${dbHost}`); - console.log(` Port: ${dbPort}`); - console.log(` Username: ${dbUsername}`); - console.log(` Database: ${dbDatabase}`); - console.log(` Password: ${dbPassword ? '***' : '(없음)'}\n`); - - let connection: Connection | null = null; - - try { - // 1. 연결 테스트 - console.log('1️⃣ MySQL 연결 테스트...'); - connection = await createConnection({ - type: 'mysql', - host: dbHost, - port: dbPort, - username: dbUsername, - password: dbPassword, - database: dbDatabase, - entities: [Cardset, CardsetContent, CardsetIncremental, CardsetSnapshot], - synchronize: false, - logging: false, - }); - - console.log(' ✅ MySQL 연결 성공!\n'); - - // 2. 테이블 존재 확인 - console.log('2️⃣ 테이블 존재 확인...'); - const queryRunner = connection.createQueryRunner(); - - const tables = [ - 'card_set', - 'cardset_contents', - 'cardset_incrementals', - 'cardset_snapshots', - ]; - - for (const tableName of tables) { - const tableExists = await queryRunner.hasTable(tableName); - console.log( - ` ${tableExists ? '✅' : '❌'} ${tableName}: ${tableExists ? '존재' : '없음'}`, - ); - } - console.log(''); - - // 3. cardset_contents 테이블 구조 확인 - console.log('3️⃣ cardset_contents 테이블 구조 확인...'); - const cardsetContentTable = await queryRunner.getTable('cardset_contents'); - if (cardsetContentTable) { - console.log(' ✅ 테이블 존재'); - console.log(` 컬럼 수: ${cardsetContentTable.columns.length}`); - cardsetContentTable.columns.forEach((col) => { - console.log(` - ${col.name} (${col.type})`); - }); - } else { - console.log(' ❌ 테이블 없음'); - } - console.log(''); - - // 4. cardset_incrementals 테이블 구조 확인 - console.log('4️⃣ cardset_incrementals 테이블 구조 확인...'); - const incrementalTable = await queryRunner.getTable('cardset_incrementals'); - if (incrementalTable) { - console.log(' ✅ 테이블 존재'); - console.log(` 컬럼 수: ${incrementalTable.columns.length}`); - incrementalTable.columns.forEach((col) => { - console.log(` - ${col.name} (${col.type})`); - }); - } else { - console.log(' ❌ 테이블 없음'); - } - console.log(''); - - // 5. 샘플 데이터 조회 테스트 - console.log('5️⃣ 샘플 데이터 조회 테스트...'); - try { - const cardsetRepository = connection.getRepository(Cardset); - const count = await cardsetRepository.count(); - console.log(` ✅ cardset 테이블 레코드 수: ${count}`); - } catch (error) { - console.log(` ⚠️ cardset 조회 실패: ${error instanceof Error ? error.message : String(error)}`); - } - - try { - const contentRepository = connection.getRepository(CardsetContent); - const count = await contentRepository.count(); - console.log(` ✅ cardset_contents 테이블 레코드 수: ${count}`); - } catch (error) { - console.log(` ⚠️ cardset_contents 조회 실패: ${error instanceof Error ? error.message : String(error)}`); - } - - try { - const incrementalRepository = connection.getRepository(CardsetIncremental); - const count = await incrementalRepository.count(); - console.log(` ✅ cardset_incrementals 테이블 레코드 수: ${count}`); - } catch (error) { - console.log(` ⚠️ cardset_incrementals 조회 실패: ${error instanceof Error ? error.message : String(error)}`); - } - console.log(''); - - // 6. INSERT 테스트 (트랜잭션으로 롤백) - console.log('6️⃣ INSERT 테스트 (트랜잭션 롤백)...'); - const queryRunner2 = connection.createQueryRunner(); - await queryRunner2.connect(); - await queryRunner2.startTransaction(); - - try { - // 테스트용 INSERT (실제로는 저장하지 않음) - await queryRunner2.query( - 'SELECT 1 as test', - ); - console.log(' ✅ 쿼리 실행 성공'); - await queryRunner2.rollbackTransaction(); - console.log(' ✅ 트랜잭션 롤백 성공\n'); - } catch (error) { - await queryRunner2.rollbackTransaction(); - throw error; - } finally { - await queryRunner2.release(); - } - - console.log('🎉 모든 MySQL 테스트 통과!'); - } catch (error) { - console.error('❌ 오류 발생:', error); - if (error instanceof Error) { - console.error(` 메시지: ${error.message}`); - if (error.stack) { - console.error(` 스택: ${error.stack.split('\n').slice(0, 5).join('\n')}`); - } - } - process.exit(1); - } finally { - if (connection && connection.isConnected) { - await connection.close(); - console.log('\n🔌 MySQL 연결 종료'); - } - } -} - -testMySQLConnection().catch(console.error); - diff --git a/test-redis.ts b/test-redis.ts deleted file mode 100644 index 01e12d7..0000000 --- a/test-redis.ts +++ /dev/null @@ -1,82 +0,0 @@ -import Redis from 'ioredis'; -import * as Y from 'yjs'; -import { config } from 'dotenv'; - -// .env 파일 로드 -config(); - -async function testRedis() { - console.log('🔌 Redis 연결 테스트 시작...\n'); - - const redisHost = process.env.REDIS_HOST || 'localhost'; - const redisPort = Number(process.env.REDIS_PORT) || 6379; - const redisPassword = process.env.REDIS_PASSWORD; - - console.log(`📋 설정 정보:`); - console.log(` Host: ${redisHost}`); - console.log(` Port: ${redisPort}`); - console.log(` Password: ${redisPassword ? '***' : '(없음)'}\n`); - - const redisClient = new Redis({ - host: redisHost, - port: redisPort, - password: redisPassword, - retryStrategy: (times) => { - const delay = Math.min(times * 50, 2000); - return delay; - }, - }); - - try { - // 연결 테스트 - console.log('1️⃣ 연결 테스트...'); - await redisClient.ping(); - console.log(' ✅ Redis 연결 성공!\n'); - - // Yjs 문서 저장 테스트 - console.log('2️⃣ Yjs 문서 저장 테스트...'); - const testDoc = new Y.Doc(); - const testArray = testDoc.getArray('test'); - testArray.push(['test1', 'test2', 'test3']); - - const state = Y.encodeStateAsUpdate(testDoc); - const testKey = 'yjs:cardset:test-123'; - await redisClient.set(testKey, Buffer.from(state)); - await redisClient.expire(testKey, 60); - console.log(' ✅ Yjs 문서 저장 성공!\n'); - - // Yjs 문서 로드 테스트 - console.log('3️⃣ Yjs 문서 로드 테스트...'); - const loadedData = await redisClient.getBuffer(testKey); - if (loadedData) { - const loadedDoc = new Y.Doc(); - Y.applyUpdate(loadedDoc, loadedData); - const loadedArray = loadedDoc.getArray('test'); - console.log(` ✅ 문서 로드 성공! 데이터: ${JSON.stringify(loadedArray.toArray())}\n`); - } else { - console.log(' ❌ 문서를 찾을 수 없습니다.\n'); - } - - // 테스트 데이터 삭제 - console.log('4️⃣ 테스트 데이터 정리...'); - await redisClient.del(testKey); - console.log(' ✅ 테스트 데이터 삭제 완료!\n'); - - console.log('🎉 모든 테스트 통과!'); - } catch (error) { - console.error('❌ 오류 발생:', error); - if (error instanceof Error) { - console.error(` 메시지: ${error.message}`); - } - } finally { - redisClient.disconnect(); - console.log('\n🔌 Redis 연결 종료'); - } -} - -// 환경변수 로드 (필요시) -import * as dotenv from 'dotenv'; -dotenv.config(); - -testRedis().catch(console.error); - diff --git a/test-websocket-methods.ts b/test-websocket-methods.ts deleted file mode 100644 index 73f221a..0000000 --- a/test-websocket-methods.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * WebSocket Gateway 각 메서드 개별 테스트 - * 실제 서버가 실행 중이어야 합니다 (npm run start:dev) - */ -import { io, Socket } from 'socket.io-client'; -import * as Y from 'yjs'; -import { config } from 'dotenv'; - -config(); - -const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'; -const TEST_TOKEN = process.env.TEST_TOKEN || ''; - -let client: Socket | null = null; - -// 유틸리티 함수 -function createClient(): Socket { - const client = io(`${SERVER_URL}/cardsets`, { - auth: { - token: TEST_TOKEN, - }, - transports: ['websocket'], - }); - - // 모든 이벤트 로깅 (디버깅용) - client.onAny((event, ...args) => { - console.log(` 📨 이벤트 수신: ${event}`, args.length > 0 ? JSON.stringify(args[0]) : ''); - }); - - // 에러 이벤트 핸들러 - client.on('error', (error) => { - console.error(` ❌ 에러 이벤트:`, error); - }); - - client.on('connect_error', (error) => { - console.error(` ❌ 연결 에러:`, error.message); - }); - - return client; -} - -function waitForConnection(client: Socket, timeout = 5000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('연결 타임아웃')); - }, timeout); - - client.on('connect', () => { - clearTimeout(timer); - resolve(); - }); - - client.on('connect_error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -function waitForEvent( - client: Socket, - event: string, - timeout = 5000, -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`${event} 이벤트 타임아웃 (${timeout}ms)`)); - }, timeout); - - client.once(event, (data: T) => { - clearTimeout(timer); - resolve(data); - }); - }); -} - -// 테스트 1: join-cardset 메서드 -async function testJoinCardset() { - console.log('\n📋 테스트 1: join-cardset 메서드'); - console.log('=' .repeat(50)); - - const cardsetId = 'test-join-1'; - client = createClient(); - - try { - // 연결 - await waitForConnection(client); - console.log('✅ WebSocket 연결 성공'); - - // 에러 이벤트도 확인 - let errorReceived = false; - client.once('error', (error: any) => { - errorReceived = true; - console.error(` ❌ 서버에서 에러 수신:`, error); - if (error.details) { - console.error(` 📋 에러 상세: ${error.details}`); - } - }); - - // sync 이벤트 대기 - const syncPromise = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - 10000, // 타임아웃 10초로 증가 - ); - - // join-cardset 이벤트 전송 - console.log(`📤 join-cardset 전송: ${cardsetId}`); - console.log(` ⏳ sync 이벤트 대기 중... (최대 10초)`); - client.emit('join-cardset', { cardsetId }); - - // sync 이벤트 수신 - const syncData = await syncPromise; - console.log('✅ sync 이벤트 수신 성공'); - console.log(` cardsetId: ${syncData.cardsetId}`); - console.log(` update 길이: ${syncData.update.length} bytes`); - - // 문서 복원 검증 - const doc = new Y.Doc(); - Y.applyUpdate(doc, new Uint8Array(syncData.update)); - console.log('✅ 문서 복원 성공'); - - client.disconnect(); - console.log('✅ 테스트 1 완료\n'); - return true; - } catch (error) { - console.error('❌ 테스트 1 실패:', error); - if (client) client.disconnect(); - return false; - } -} - -// 테스트 2: update 메서드 -async function testUpdate() { - console.log('\n📋 테스트 2: update 메서드'); - console.log('=' .repeat(50)); - - const cardsetId = 'test-update-1'; - client = createClient(); - - try { - // 연결 - await waitForConnection(client); - console.log('✅ WebSocket 연결 성공'); - - // 먼저 조인 - const initialSync = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - client.emit('join-cardset', { cardsetId }); - const initialData = await initialSync; - console.log('✅ 조인 완료'); - - // 문서 수정 - const doc = new Y.Doc(); - Y.applyUpdate(doc, new Uint8Array(initialData.update)); - const testMap = doc.getMap('test'); - testMap.set('key1', 'value1'); - testMap.set('key2', 'value2'); - - // 업데이트 생성 - const update = Y.encodeStateAsUpdate(doc); - const updateArray = Array.from(update); - - // 업데이트 sync 이벤트 대기 - const updateSync = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - - // update 이벤트 전송 - console.log(`📤 update 전송: ${cardsetId}`); - client.emit('update', { cardsetId, update: updateArray }); - - // 업데이트 sync 이벤트 수신 - const updateData = await updateSync; - console.log('✅ 업데이트 sync 이벤트 수신 성공'); - console.log(` update 길이: ${updateData.update.length} bytes`); - - // 업데이트 검증 - const updatedDoc = new Y.Doc(); - Y.applyUpdate(updatedDoc, new Uint8Array(updateData.update)); - const updatedMap = updatedDoc.getMap('test'); - console.log(` key1: ${updatedMap.get('key1')}`); - console.log(` key2: ${updatedMap.get('key2')}`); - - client.disconnect(); - console.log('✅ 테스트 2 완료\n'); - return true; - } catch (error) { - console.error('❌ 테스트 2 실패:', error); - if (client) client.disconnect(); - return false; - } -} - -// 테스트 3: leave-cardset 메서드 -async function testLeaveCardset() { - console.log('\n📋 테스트 3: leave-cardset 메서드'); - console.log('=' .repeat(50)); - - const cardsetId = 'test-leave-1'; - client = createClient(); - - try { - // 연결 - await waitForConnection(client); - console.log('✅ WebSocket 연결 성공'); - - // 먼저 조인 - const syncPromise = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - client.emit('join-cardset', { cardsetId }); - await syncPromise; - console.log('✅ 조인 완료'); - - // leave-cardset 이벤트 전송 - console.log(`📤 leave-cardset 전송: ${cardsetId}`); - client.emit('leave-cardset', { cardsetId }); - - // 잠시 대기 (서버 처리 시간) - await new Promise((resolve) => setTimeout(resolve, 500)); - - client.disconnect(); - console.log('✅ 테스트 3 완료\n'); - return true; - } catch (error) { - console.error('❌ 테스트 3 실패:', error); - if (client) client.disconnect(); - return false; - } -} - -// 테스트 4: update - 증분 업데이트 테스트 -async function testIncrementalUpdate() { - console.log('\n📋 테스트 4: 증분 업데이트 테스트'); - console.log('=' .repeat(50)); - - const cardsetId = 'test-incremental-1'; - client = createClient(); - - try { - // 연결 - await waitForConnection(client); - console.log('✅ WebSocket 연결 성공'); - - // 조인 - const initialSync = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - client.emit('join-cardset', { cardsetId }); - const initialData = await initialSync; - console.log('✅ 조인 완료'); - - // 첫 번째 업데이트 - const doc1 = new Y.Doc(); - Y.applyUpdate(doc1, new Uint8Array(initialData.update)); - const map1 = doc1.getMap('test'); - map1.set('step1', 'value1'); - - const update1 = Y.encodeStateAsUpdate(doc1); - const update1Array = Array.from(update1); - - const sync1 = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - client.emit('update', { cardsetId, update: update1Array }); - await sync1; - console.log('✅ 첫 번째 업데이트 완료'); - - // 두 번째 업데이트 (증분) - const doc2 = new Y.Doc(); - Y.applyUpdate(doc2, new Uint8Array(initialData.update)); - Y.applyUpdate(doc2, update1); - const map2 = doc2.getMap('test'); - map2.set('step2', 'value2'); - - const update2 = Y.encodeStateAsUpdate(doc2); - const update2Array = Array.from(update2); - - const sync2 = waitForEvent<{ cardsetId: string; update: number[] }>( - client, - 'sync', - ); - client.emit('update', { cardsetId, update: update2Array }); - const sync2Data = await sync2; - console.log('✅ 두 번째 업데이트 완료'); - - // 최종 상태 검증 - const finalDoc = new Y.Doc(); - Y.applyUpdate(finalDoc, new Uint8Array(sync2Data.update)); - const finalMap = finalDoc.getMap('test'); - console.log(` step1: ${finalMap.get('step1')}`); - console.log(` step2: ${finalMap.get('step2')}`); - - client.disconnect(); - console.log('✅ 테스트 4 완료\n'); - return true; - } catch (error) { - console.error('❌ 테스트 4 실패:', error); - if (client) client.disconnect(); - return false; - } -} - -// 메인 실행 함수 -async function main() { - console.log('🧪 WebSocket Gateway 메서드 개별 테스트 시작'); - console.log(`📋 서버 URL: ${SERVER_URL}\n`); - - if (!TEST_TOKEN) { - console.warn('⚠️ TEST_TOKEN이 설정되지 않았습니다.'); - console.warn(' (서버에서 SKIP_WS_AUTH=true로 설정되어 있으면 문제없습니다)\n'); - } - - console.log('💡 팁: 서버 터미널에서 다음 로그를 확인하세요:'); - console.log(' - "User test-user joining cardset ..."'); - console.log(' - "User test-user joined cardset ..."'); - console.log(' - 에러 메시지가 있다면 확인하세요\n'); - - const results = { - joinCardset: false, - update: false, - leaveCardset: false, - incrementalUpdate: false, - }; - - try { - results.joinCardset = await testJoinCardset(); - results.update = await testUpdate(); - results.leaveCardset = await testLeaveCardset(); - results.incrementalUpdate = await testIncrementalUpdate(); - - // 결과 요약 - console.log('\n' + '='.repeat(50)); - console.log('📊 테스트 결과 요약'); - console.log('='.repeat(50)); - console.log(`✅ join-cardset: ${results.joinCardset ? '통과' : '실패'}`); - console.log(`✅ update: ${results.update ? '통과' : '실패'}`); - console.log(`✅ leave-cardset: ${results.leaveCardset ? '통과' : '실패'}`); - console.log( - `✅ 증분 업데이트: ${results.incrementalUpdate ? '통과' : '실패'}`, - ); - console.log('='.repeat(50)); - - const allPassed = Object.values(results).every((r) => r === true); - if (allPassed) { - console.log('\n🎉 모든 테스트 통과!'); - process.exit(0); - } else { - console.log('\n❌ 일부 테스트 실패'); - process.exit(1); - } - } catch (error) { - console.error('\n❌ 테스트 실행 중 오류:', error); - process.exit(1); - } -} - -main().catch(console.error); - diff --git a/test-websocket-simple.ts b/test-websocket-simple.ts deleted file mode 100644 index 8a98ff5..0000000 --- a/test-websocket-simple.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 간단한 WebSocket 기능 검증 스크립트 - * 실제 서버 없이 코드 로직만 검증 - */ -import * as Y from 'yjs'; - -console.log('🧪 WebSocket 핸들러 로직 검증 시작...\n'); - -// 1. join-cardset 로직 검증 -console.log('1️⃣ join-cardset 로직 검증...'); -function testJoinCardset() { - const cardsetId = 'test-123'; - const doc = new Y.Doc(); - const testMap = doc.getMap('content'); - testMap.set('key1', 'value1'); - - // 문서 상태 인코딩 (실제 join-cardset에서 하는 작업) - const state = Y.encodeStateAsUpdate(doc); - const updateArray = Array.from(state); - - // 클라이언트가 받을 sync 이벤트 데이터 - const syncData = { - cardsetId, - update: updateArray, - }; - - console.log(' ✅ 문서 상태 인코딩 성공'); - console.log(` cardsetId: ${syncData.cardsetId}`); - console.log(` update 길이: ${syncData.update.length} bytes\n`); - - // 클라이언트가 받은 데이터로 문서 복원 - const clientDoc = new Y.Doc(); - Y.applyUpdate(clientDoc, new Uint8Array(syncData.update)); - const clientMap = clientDoc.getMap('content'); - console.log(` ✅ 클라이언트 문서 복원 성공`); - console.log(` key1: ${clientMap.get('key1')}\n`); - - return true; -} - -// 2. update 로직 검증 -console.log('2️⃣ update 로직 검증...'); -function testUpdate() { - const cardsetId = 'test-456'; - - // 서버의 문서 상태 - const serverDoc = new Y.Doc(); - const serverMap = serverDoc.getMap('content'); - serverMap.set('initial', 'value'); - - // 클라이언트가 보낼 업데이트 - const clientDoc = new Y.Doc(); - Y.applyUpdate(clientDoc, Y.encodeStateAsUpdate(serverDoc)); // 초기 상태 동기화 - const clientMap = clientDoc.getMap('content'); - clientMap.set('newKey', 'newValue'); // 수정 - - // 클라이언트가 보내는 증분 업데이트 - const incrementalUpdate = Y.encodeStateAsUpdate(clientDoc); - const updateArray = Array.from(incrementalUpdate); - - console.log(' ✅ 증분 업데이트 생성 성공'); - console.log(` update 길이: ${updateArray.length} bytes`); - - // 서버에서 업데이트 적용 - Y.applyUpdate(serverDoc, new Uint8Array(updateArray)); - console.log(` ✅ 서버 문서에 업데이트 적용 성공`); - console.log(` initial: ${serverMap.get('initial')}`); - console.log(` newKey: ${serverMap.get('newKey')}\n`); - - // 브로드캐스트할 전체 상태 - const broadcastState = Y.encodeStateAsUpdate(serverDoc); - console.log(` ✅ 브로드캐스트 상태 생성 성공`); - console.log(` broadcast 길이: ${Array.from(broadcastState).length} bytes\n`); - - return true; -} - -// 3. leave-cardset 로직 검증 -console.log('3️⃣ leave-cardset 로직 검증...'); -function testLeaveCardset() { - const cardsetId = 'test-789'; - console.log(` ✅ leave-cardset 이벤트 처리 준비 완료`); - console.log(` cardsetId: ${cardsetId}\n`); - return true; -} - -// 테스트 실행 -try { - const results = [ - testJoinCardset(), - testUpdate(), - testLeaveCardset(), - ]; - - if (results.every((r) => r === true)) { - console.log('🎉 모든 WebSocket 핸들러 로직 검증 통과!'); - console.log('\n📝 요약:'); - console.log(' ✅ join-cardset: 문서 로드 및 sync 이벤트 전송 로직 정상'); - console.log(' ✅ update: 증분 업데이트 적용 및 브로드캐스트 로직 정상'); - console.log(' ✅ leave-cardset: 이벤트 처리 준비 완료'); - console.log('\n⚠️ 주의: 실제 WebSocket 연결 테스트는 서버 실행 후'); - console.log(' "npm run test:websocket" 명령어를 사용하세요.'); - } -} catch (error) { - console.error('❌ 오류 발생:', error); - process.exit(1); -} - diff --git a/test-yjs-integration.ts b/test-yjs-integration.ts deleted file mode 100644 index 69d5b0f..0000000 --- a/test-yjs-integration.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Yjs 문서 서비스 통합 테스트 - * 실제 Redis 연결이 필요합니다 - */ -import { config } from 'dotenv'; -import Redis from 'ioredis'; -import * as Y from 'yjs'; - -config(); - -async function testYjsIntegration() { - console.log('🧪 Yjs 통합 테스트 시작...\n'); - - const redisHost = process.env.REDIS_HOST || 'localhost'; - const redisPort = Number(process.env.REDIS_PORT) || 6379; - const redisPassword = process.env.REDIS_PASSWORD; - - const redisClient = new Redis({ - host: redisHost, - port: redisPort, - password: redisPassword, - retryStrategy: (times) => { - const delay = Math.min(times * 50, 2000); - return delay; - }, - }); - - try { - // 1. 연결 테스트 - console.log('1️⃣ Redis 연결 테스트...'); - await redisClient.ping(); - console.log(' ✅ Redis 연결 성공!\n'); - - // 2. 문서 저장 및 로드 - console.log('2️⃣ 문서 저장 및 로드 테스트...'); - const cardsetId = 'test-integration-123'; - const doc = new Y.Doc(); - const testMap = doc.getMap('content'); - testMap.set('key1', 'value1'); - testMap.set('key2', 'value2'); - - const state = Y.encodeStateAsUpdate(doc); - const key = `yjs:cardset:${cardsetId}`; - await redisClient.set(key, Buffer.from(state)); - await redisClient.expire(key, 60); - - const loadedData = await redisClient.getBuffer(key); - if (loadedData) { - const loadedDoc = new Y.Doc(); - Y.applyUpdate(loadedDoc, loadedData); - const loadedMap = loadedDoc.getMap('content'); - console.log(` ✅ 문서 로드 성공!`); - console.log(` key1: ${loadedMap.get('key1')}`); - console.log(` key2: ${loadedMap.get('key2')}\n`); - } - - // 3. 증분 업데이트 리스트 테스트 - console.log('3️⃣ 증분 업데이트 리스트 테스트...'); - const historyKey = `yjs:cardset:${cardsetId}:updates`; - const update1 = Buffer.from([1, 2, 3]); - const update2 = Buffer.from([4, 5, 6]); - - await redisClient.rpush(historyKey, update1.toString('base64')); - await redisClient.rpush(historyKey, update2.toString('base64')); - - const updates = await redisClient.lrange(historyKey, 0, -1); - console.log(` ✅ 증분 업데이트 저장 성공! (${updates.length}개)\n`); - - // 4. 클라이언트 관리 테스트 - console.log('4️⃣ 클라이언트 관리 테스트...'); - const cardsetKey = `yjs:cardset:${cardsetId}:clients`; - const clientId = 'test-client-1'; - - await redisClient.sadd(cardsetKey, clientId); - const count = await redisClient.scard(cardsetKey); - console.log(` ✅ 클라이언트 등록 성공! (활성 클라이언트: ${count}명)\n`); - - await redisClient.srem(cardsetKey, clientId); - const countAfter = await redisClient.scard(cardsetKey); - console.log( - ` ✅ 클라이언트 해제 성공! (활성 클라이언트: ${countAfter}명)\n`, - ); - - // 5. 정리 - console.log('5️⃣ 테스트 데이터 정리...'); - await redisClient.del(key); - await redisClient.del(historyKey); - await redisClient.del(cardsetKey); - console.log(' ✅ 테스트 데이터 삭제 완료!\n'); - - console.log('🎉 모든 통합 테스트 통과!'); - } catch (error) { - console.error('❌ 오류 발생:', error); - if (error instanceof Error) { - console.error(` 메시지: ${error.message}`); - } - process.exit(1); - } finally { - redisClient.disconnect(); - console.log('\n🔌 Redis 연결 종료'); - } -} - -testYjsIntegration().catch(console.error); - From 3c0d70ce8e985849a482eaeee0eb1699fd994bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 25 Dec 2025 00:07:48 +0900 Subject: [PATCH 29/35] =?UTF-8?q?Delete:=20=EC=9E=84=EC=8B=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- websocket-test.html | 509 -------------------------------------------- 1 file changed, 509 deletions(-) delete mode 100644 websocket-test.html diff --git a/websocket-test.html b/websocket-test.html deleted file mode 100644 index 5734421..0000000 --- a/websocket-test.html +++ /dev/null @@ -1,509 +0,0 @@ - - - - - - WebSocket 테스트 - Flip Note - - - - - -
-
-

🔌 WebSocket 연결 테스트

-

Flip Note 카드셋 협업 기능 테스트

-
- -
- -
-

📡 연결 설정

-
- - -
-
- - -
-
- - -
-
- - -
-
연결 안됨
-
- - -
-

🎮 카드셋 조작

-
- - -
-
- - -
-

📝 Yjs 문서 테스트

-
- - -
-
- - -
-
- - -
-

📋 이벤트 로그

-
- -
-
-
- - - - - From b7cfcef594b187f0394154dc49b9666f512dee3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 25 Dec 2025 00:08:06 +0900 Subject: [PATCH 30/35] =?UTF-8?q?Refactor:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index cf80cfe..afb8f6d 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -7,7 +7,7 @@ export class CardsetController { constructor(private readonly cardsetService: CardsetService) {} @Post(':cardSetId') - async saveCardsetSnapshot( + async saveCardsetContent( @Param('cardSetId', ParseIntPipe) cardSetId: number, ) { await this.cardsetService.saveCardsetContent(cardSetId); From 3ba77f8709fbd8de06b870fb4ee75ca705f9c52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 25 Dec 2025 00:08:58 +0900 Subject: [PATCH 31/35] =?UTF-8?q?Fix:=20=EC=88=AB=EC=9E=90=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/websocket.gateway.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 14ddccc..d36f94e 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -304,9 +304,18 @@ export class CollaborationGateway * DB에서 문서를 로드하거나 없으면 새로 생성 * DB에서 로드한 경우 Redis에 저장 */ - private async loadDocumentFromDBOrCreate(cardsetId: string): Promise { + private async loadDocumentFromDBOrCreate( + cardsetId: string, + ): Promise { const numericCardsetId = Number(cardsetId); + if (Number.isNaN(numericCardsetId)) { + this.logger.error( + `Cannot load document from DB for cardset ${cardsetId}: invalid numeric id`, + ); + return null; + } + try { // DB에서 로드 시도 const doc = From 29a4b898abbe5a6a2ad9fd45afd2afe87b72ab5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 25 Dec 2025 00:09:14 +0900 Subject: [PATCH 32/35] =?UTF-8?q?Fix:=20=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/websocket/yjs-document.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts index 6ec0562..66877b8 100644 --- a/src/websocket/yjs-document.service.ts +++ b/src/websocket/yjs-document.service.ts @@ -260,6 +260,13 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { try { // Redis에서 증분 업데이트 리스트 조회 + if (!this.redisClient) { + this.logger.error( + `Redis client not initialized for cardset ${cardsetId}`, + ); + return; + } + const updates = await this.redisClient.lrange(historyKey, 0, -1); if (updates.length === 0) { this.logger.debug( @@ -300,6 +307,10 @@ export class YjsDocumentService implements OnModuleInit, OnModuleDestroy { try { const key = `yjs:cardset:${cardsetId}`; await this.redisClient.del(key); + + const historyKey = `yjs:cardset:${cardsetId}:updates`; + await this.redisClient.del(historyKey); + this.logger.debug(`Deleted Yjs document for cardset ${cardsetId}`); } catch (error) { this.logger.error( From 4a5b0f9aae3f3a0d8a1ce3c8762168c165d8eb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 26 Dec 2025 23:36:03 +0900 Subject: [PATCH 33/35] =?UTF-8?q?Fix:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index d0a4ecd..519ad62 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,8 +32,8 @@ import { WebSocketModule } from './websocket/websocket.module'; Card, ], synchronize: - process.env.DB_SYNCHRONIZE === 'true' || - process.env.NODE_ENV !== 'production', + process.env.NODE_ENV !== 'production' && + process.env.DB_SYNCHRONIZE === 'true', dropSchema: false, // 기존 스키마 보존 migrationsRun: false, // 마이그레이션 자동 실행 비활성화 }), From bc4fbc25e2ee4a57d7691817774b1cfbd28fedba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 26 Dec 2025 23:37:51 +0900 Subject: [PATCH 34/35] =?UTF-8?q?Fix:=20=EC=9D=B8=EC=A6=9D=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/ws-auth.guard.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/auth/ws-auth.guard.ts b/src/auth/ws-auth.guard.ts index 38d3552..9188c4c 100644 --- a/src/auth/ws-auth.guard.ts +++ b/src/auth/ws-auth.guard.ts @@ -16,21 +16,21 @@ export class WsAuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const client: Socket = context.switchToWs().getClient(); - // TODO: 테스트용 - JWT 인증 임시 비활성화 - // 실제 배포 시에는 아래 주석을 해제하고 테스트 코드를 제거해야 합니다 - const SKIP_AUTH = process.env.SKIP_WS_AUTH === 'true' || true; // 테스트용: true로 고정 - - if (SKIP_AUTH) { - // 테스트용 더미 사용자 데이터 - (client.data as { user: unknown }).user = { - userId: 'test-user', - email: 'test@example.com', - }; - this.logger.warn( - `⚠️ 테스트 모드: 인증을 건너뛰고 있습니다 (client ${client.id})`, - ); - return true; - } + // // TODO: 테스트용 - JWT 인증 임시 비활성화 + // // 실제 배포 시에는 아래 주석을 해제하고 테스트 코드를 제거해야 합니다 + // const SKIP_AUTH = process.env.SKIP_WS_AUTH === 'true' || true; // 테스트용: true로 고정 + + // if (SKIP_AUTH) { + // // 테스트용 더미 사용자 데이터 + // (client.data as { user: unknown }).user = { + // userId: 'test-user', + // email: 'test@example.com', + // }; + // this.logger.warn( + // `⚠️ 테스트 모드: 인증을 건너뛰고 있습니다 (client ${client.id})`, + // ); + // return true; + // } // 1) socket.io v4: client.handshake.auth.token 로 받기 // 2) 또는 Authorization 헤더로 받기 From 817337d1e4130c79737a91e207f1bdc46f5b34bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Wed, 31 Dec 2025 19:18:34 +0900 Subject: [PATCH 35/35] =?UTF-8?q?Refactor:=20=EC=B9=B4=EB=93=9C=EC=85=8B?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EB=94=94=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/cardset.service.ts | 6 +++++- src/common/interfaces/api-response.interface.ts | 6 ++++++ src/common/utils/response.util.ts | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 60ef3a9..8cb5564 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -38,12 +38,16 @@ export class CardsetService { const answerText = (cardMap as Y.Map)?.get('answer') as | Y.Text | undefined; + const idText = (cardMap as Y.Map)?.get('id') as + | Y.Text + | undefined; const question = questionText ? (questionText as unknown as string) : ''; const answer = answerText ? (answerText as unknown as string) : ''; + const id = idText ? (idText as unknown as string) : ''; return { - id: randomUUID(), + id, question, answer, }; diff --git a/src/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts index 65f82fd..822ad29 100644 --- a/src/common/interfaces/api-response.interface.ts +++ b/src/common/interfaces/api-response.interface.ts @@ -7,3 +7,9 @@ export interface ApiResponse { message: string | null; data: T; } + + + + + + diff --git a/src/common/utils/response.util.ts b/src/common/utils/response.util.ts index 2f8629c..74f012d 100644 --- a/src/common/utils/response.util.ts +++ b/src/common/utils/response.util.ts @@ -39,3 +39,9 @@ export function errorResponse( data: null, }; } + + + + + +