diff --git a/package-lock.json b/package-lock.json index c06a38c..bdbf366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.6", - "@types/socket.io": "^3.0.1", + "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", @@ -42,12 +42,14 @@ "@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", "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", @@ -1371,6 +1373,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", @@ -3064,15 +3072,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", @@ -4710,6 +4709,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", @@ -5092,9 +5100,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" @@ -5118,6 +5127,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", @@ -5207,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", @@ -6571,6 +6646,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", @@ -7853,11 +7952,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", @@ -9030,6 +9141,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", @@ -9512,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", @@ -9688,6 +9854,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", @@ -10700,6 +10872,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", @@ -11405,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 d5e40d6..ddb7ccf 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,13 @@ "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", + "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", @@ -29,6 +35,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,12 +59,14 @@ "@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", "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..519ad62 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,8 +23,19 @@ 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], - synchronize: false, + entities: [ + CardSet, + CardsetSnapshot, + CardsetIncremental, + CardsetContent, + CardSetManager, + Card, + ], + synchronize: + process.env.NODE_ENV !== 'production' && + process.env.DB_SYNCHRONIZE === 'true', + dropSchema: false, // 기존 스키마 보존 + migrationsRun: false, // 마이그레이션 자동 실행 비활성화 }), CardsetModule, CardsetManagerModule, 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/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/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/auth/ws-auth.guard.ts b/src/auth/ws-auth.guard.ts new file mode 100644 index 0000000..9188c4c --- /dev/null +++ b/src/auth/ws-auth.guard.ts @@ -0,0 +1,61 @@ +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(); + + // // 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 = + (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/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.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.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/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-manager/entities/cardset-manager.entity.ts b/src/cardset-manager/entities/cardset-manager.entity.ts index 823437b..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(['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.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/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index f4fd1c4..afb8f6d 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -1,7 +1,22 @@ -import { Controller } 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('cardset') +@Controller('card-sets') export class CardsetController { constructor(private readonly cardsetService: CardsetService) {} + + @Post(':cardSetId') + async saveCardsetContent( + @Param('cardSetId', ParseIntPipe) cardSetId: number, + ) { + await this.cardsetService.saveCardsetContent(cardSetId); + 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.module.ts b/src/cardset/cardset.module.ts index 2153f26..628cdc4 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,9 +1,18 @@ -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, CardsetContent]), + forwardRef(() => WebSocketModule), + ], controllers: [CardsetController], providers: [CardsetService], + exports: [CardsetService], }) export class CardsetModule {} 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 927df7f..8cb5564 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,4 +1,215 @@ -import { Injectable } from '@nestjs/common'; +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'; @Injectable() -export class CardsetService {} +export class CardsetService { + private readonly logger = new Logger(CardsetService.name); + + 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 } }); + } + + /** + * Yjs 배열에서 카드 데이터를 추출하여 객체 배열로 변환 + * @param cardsArray Yjs 배열 + * @returns 카드 객체 배열 [{id: string, question: string, answer: string}, ...] + */ + private extractCardsFromYjsArray( + cardsArray: Y.Array, + ): Array<{ id: string; 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 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, + question, + answer, + }; + }); + } + + /** + * 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) { + 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') { + // 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); + 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; + } + } + + 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 cardsArray = doc.getArray('cards'); + + // 카드 배열을 객체 배열로 변환 + const cardsList = this.extractCardsFromYjsArray(cardsArray); + + // 배열을 문자열로 변환 + const cardsListString = JSON.stringify(cardsList); + this.logger.log( + `[saveCardsetContent] Cardset ${cardSetId} - Cards list: ${cardsListString}`, + ); + + //카드셋 내용 없으면 새로 생성 + let cardsetContent = await this.cardsetContentRepository.findOne({ + where: { cardset: { id: cardSetId } }, + }); + + //카드셋 내용 없으면 새로 생성 + if (!cardsetContent) { + cardsetContent = this.cardsetContentRepository.create({ + cardset, + content: '', + }); + } + + //카드셋 내용 저장 + cardsetContent.content = cardsListString; + + await this.cardsetContentRepository.save(cardsetContent); + + 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/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/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts new file mode 100644 index 0000000..822ad29 --- /dev/null +++ b/src/common/interfaces/api-response.interface.ts @@ -0,0 +1,15 @@ +/** + * 공통 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..74f012d --- /dev/null +++ b/src/common/utils/response.util.ts @@ -0,0 +1,47 @@ +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/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/main.ts b/src/main.ts index 6722a94..ed466e4 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 어댑터 설정 @@ -32,6 +35,10 @@ 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/websocket.gateway.integration.spec.ts b/src/websocket/websocket.gateway.integration.spec.ts new file mode 100644 index 0000000..1f0b045 --- /dev/null +++ b/src/websocket/websocket.gateway.integration.spec.ts @@ -0,0 +1,253 @@ +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 { 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; + let yjsDocumentService: YjsDocumentService; + 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 = { + 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(), + }), + }; + + 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: { + 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: authConfig.KEY, + useValue: { + jwtSecret: 'test-secret', + }, + }, + { + 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; + + // Redis 클라이언트 모킹 + (yjsDocumentService as unknown as { redisClient: unknown }).redisClient = + mockRedisClient; + }); + + 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 = '1'; + const user: UserAuth = { + userId: 'user-1', + role: 'user', + tokenVersion: 1, + }; + + // Redis에 문서 저장 모킹 + const doc = new Y.Doc(); + const testArray = doc.getArray('test'); + testArray.push(['data1']); + 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, { + cardsetId, + }); + + expect(mockSocket.join).toHaveBeenCalledWith(`cardset:${cardsetId}`); + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + }); + + it('should load from DB when Redis is empty', async () => { + const cardsetId = '2'; + const numericId = 2; + const user: UserAuth = { + userId: 'user-2', + role: 'user', + tokenVersion: 1, + }; + + // Redis가 비어있음을 모킹 + mockRedisClient.getBuffer.mockResolvedValue(null); + + // DB에서 로드하는 메서드 모킹 + const dbDoc = new Y.Doc(); + const dbArray = dbDoc.getArray('db-data'); + dbArray.push(['from-db']); + const loadFromDBSpy = jest + .spyOn(cardsetService, 'loadCardsetContentFromDB') + .mockResolvedValue(dbDoc); + + await gateway.handleJoinCardset(user, mockSocket as Socket, { + cardsetId, + }); + + expect(loadFromDBSpy).toHaveBeenCalledWith(numericId); + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + }); + + it('should create new document when both Redis and DB are empty', async () => { + const cardsetId = '3'; + const user: UserAuth = { + userId: 'user-3', + role: 'user', + tokenVersion: 1, + }; + + // Redis가 비어있음을 모킹 + mockRedisClient.getBuffer.mockResolvedValue(null); + + // DB에서도 null 반환 + jest + .spyOn(cardsetService, 'loadCardsetContentFromDB') + .mockResolvedValue(null); + + await gateway.handleJoinCardset(user, mockSocket as Socket, { + cardsetId, + }); + + expect(mockSocket.emit).toHaveBeenCalledWith('sync', expect.any(Object)); + }); + }); + + describe('update flow', () => { + it('should apply update and broadcast to all clients', async () => { + const cardsetId = '1'; + const user: UserAuth = { + userId: 'user-4', + role: 'user', + tokenVersion: 1, + }; + + // 문서 생성 및 유효한 업데이트 생성 + const doc = new Y.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 updateArray = Array.from(update); + + 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(mockEmit).toHaveBeenCalledWith('sync', expect.any(Object)); + }); + }); +}); diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 313809c..d36f94e 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -2,20 +2,28 @@ import { WebSocketGateway, WebSocketServer, SubscribeMessage, + ConnectedSocket, + MessageBody, OnGatewayConnection, OnGatewayDisconnect, - MessageBody, - ConnectedSocket, } from '@nestjs/websockets'; +import { Inject, Logger, UseGuards, forwardRef } from '@nestjs/common'; import { Server, Socket } from 'socket.io'; -import { Logger } from '@nestjs/common'; 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({ cors: { origin: '*', }, namespace: '/cardsets', + pingTimeout: 60000, // 60초 + pingInterval: 25000, // 25초 }) export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect @@ -23,349 +31,383 @@ export class CollaborationGateway @WebSocketServer() server: Server; + private static readonly FLUSH_DELAY_MS = 5000; + private readonly logger = new Logger(CollaborationGateway.name); - private readonly heartbeatInterval = 10000; // 10초 - private heartbeatTimers = new Map(); - private documentMap = new Map(); // documentId -> Y.Doc + private flushTimeouts = new Map(); - constructor() {} + constructor( + private readonly yjsDocumentService: YjsDocumentService, + @Inject(forwardRef(() => CardsetService)) + private readonly cardsetService: CardsetService, + ) {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); - - // 10초마다 헬스체크 시작 - this.startHeartbeat(client); } - handleDisconnect(client: Socket) { + async handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - this.logger.log( - `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, - ); - - this.stopHeartbeat(client.id); + await this.removeClientFromAllCardsets(client); } - @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 }, ) { + const { cardsetId } = data; + this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); + try { - this.logger.log( - `Client ${client.id} joining document: ${data.documentId}`, - ); + // 카드셋 룸에 조인 + void client.join(`cardset:${cardsetId}`); - // 클라이언트를 룸에 조인 - void client.join(data.documentId); + // TODO: 카드셋 룸에 조인 시 클라이언트 등록 및 플러시 스케줄링 기능 + // await this.yjsDocumentService.registerClient(cardsetId, client.id); + // this.clearScheduledFlush(cardsetId); - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); + // Redis에서 문서 로드 시도 + let doc = await this.yjsDocumentService.loadDocument(cardsetId); if (!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(); - this.documentMap.set(data.documentId, doc); } - // 클라이언트에게 현재 문서 상태 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('yjs-update', { - documentId: data.documentId, - update: Array.from(state), - }); + // 클라이언트에게 현재 카드셋 상태 전송 + this.sendSync(cardsetId, doc, client); - // 조인 성공 응답 - client.emit('joinRoom', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), + this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); + } catch (error) { + this.logger.error('Error joining cardset:', error); + this.logger.error('Error details:', { + cardsetId, + userId: user?.userId, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, }); - // 다른 클라이언트들에게 새 클라이언트 알림 - 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' }); + // 에러가 발생해도 빈 문서라도 보내서 클라이언트가 연결 유지할 수 있도록 + try { + const emptyDoc = new Y.Doc(); + 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); + this.sendError(client, { + message: 'Failed to join cardset', + details: error instanceof Error ? error.message : String(error), + }); + } } } - @SubscribeMessage('leaveRoom') - handleLeaveDocument( + // 카드셋에서 나가기 + @SubscribeMessage('leave-cardset') + 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 서비스 제거로 인한 간단한 처리 - - // 룸에서 나가기 - 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(), - }); + const { cardsetId } = data; + 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 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 }, + @SubscribeMessage('awareness') // ← 클라이언트가 보낸 "awareness" 받음 + handleAwareness( + client: Socket, + payload: { cardsetId: string; awareness: Uint8Array }, ) { - try { - this.logger.log( - `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, - ); + const { cardsetId, awareness } = payload; - // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 - 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'}`, - ); - } + // 같은 문서에 있는 클라이언트에 "awareness"로 브로드캐스트 + this.broadcastAwareness(cardsetId, awareness); } - @SubscribeMessage('yjs-message') - handleYjsMessage( + // Yjs 업데이트 (클라이언트가 변경사항을 받을 때) + @SubscribeMessage('update') + async handleUpdate( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId?: string; type: string; data: any }, + @MessageBody() data: { cardsetId: string; update?: number[] }, ) { try { + const { cardsetId, update } = data; this.logger.log( - `Received yjs-message from client ${client.id}: ${JSON.stringify(data)}`, + `Sync request from user ${user.userId} for cardset ${cardsetId}`, ); - 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 }, - ); + if (!update) { + this.sendError(client, { message: 'Update data is required' }); return; } - // documentId가 없으면 에러 - if (!documentId) { - this.logger.warn(`Document ID required for client ${client.id}`); - client.emit('error', { message: 'Document ID required' }); - return; - } - - const doc = this.documentMap.get(documentId); - + // Redis에서 문서 로드 + let doc = await this.yjsDocumentService.loadDocument(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${documentId} for client ${client.id}`, + doc = new Y.Doc(); + this.logger.log( + `Created new Yjs document for cardset ${cardsetId} during update`, ); - 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}`, - ); - } - } catch (error) { - this.logger.error( - `Error processing YJS message from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + // 클라이언트에서 온 업데이트 적용 -> 증분값 + const updateBuffer = new Uint8Array(update); + Y.applyUpdate(doc, updateBuffer); + + // Redis에 업데이트 저장 (증분값과 스냅샷 모두 저장) + await this.yjsDocumentService.saveUpdate(cardsetId, updateBuffer); + + // 업데이트 적용 후 모든 클라이언트에게 sync 브로드캐스트 + this.broadcastSync(cardsetId, doc); + this.logger.log( + `Sync update from user ${user.userId} broadcasted to all clients in cardset ${cardsetId}`, ); - this.logger.error(`Raw data: ${JSON.stringify(data)}`); + } catch (error) { + this.logger.error('Error during sync:', error); + this.sendError(client, { message: 'Sync failed' }); } } - private handleSyncMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { syncStep, update } = data as { - syncStep: number; - update?: number[]; - }; - - if (syncStep === 0) { - // Step 0: 클라이언트가 현재 상태 벡터 전송 - const stateVector = Y.encodeStateVector(doc); - client.emit('yjs-message', { - type: 'sync', - data: { 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('yjs-message', { - type: 'sync', - data: { syncStep: 1, update: Array.from(diff) }, - }); + /** + * 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); + // JSON 구조를 직접 문자열로 구성하여 중간 객체 생성 최소화 + const jsonStr = JSON.stringify({ + cardsetId, + update: Array.from(state), + }); + return Buffer.from(jsonStr, 'utf8'); + } + + /** + * sync 이벤트 전송 (통일된 인터페이스) + * @param cardsetId 카드셋 ID + * @param doc Yjs 문서 + * @param client 선택적 클라이언트 (없으면 룸 전체에 브로드캐스트) + */ + private sendSync(cardsetId: string, doc: Y.Doc, client?: Socket): void { + const blob = this.createSyncBuffer(doc, cardsetId); + if (client) { + client.emit('sync', blob); + } else { + this.server.to(`cardset:${cardsetId}`).emit('sync', blob); } } - private handleUpdateMessage( + /** + * 카드셋 룸에 sync 이벤트 브로드캐스트 (최적화된 버전) + * @param cardsetId 카드셋 ID + * @param doc Yjs 문서 + */ + private broadcastSync(cardsetId: string, doc: Y.Doc): void { + this.sendSync(cardsetId, doc); + } + + /** + * 클라이언트에게 error 이벤트 전송 (최적화된 버전) + * @param client Socket 클라이언트 + * @param errorData 에러 데이터 + */ + private sendError( 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)); + errorData: { message: string; details?: string }, + ): void { + // JSON 문자열을 직접 생성하여 중간 객체 생성 최소화 + const jsonStr = JSON.stringify(errorData); + const blob = Buffer.from(jsonStr, 'utf8'); + client.emit('error', blob); + } - // 다른 클라이언트들에게 업데이트 브로드캐스트 - client.to(documentId).emit('yjs-message', { - type: 'update', - data: { update: Array.from(update) }, + /** + * 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'); } - private handleAwarenessMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { awareness } = data as { awareness: number[] }; + /** + * 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 브로드캐스트 - client.to(documentId).emit('yjs-message', { - type: 'awareness', - data: { awareness: Array.from(awareness) }, - }); + /** + * 카드셋 룸에 awareness 이벤트 브로드캐스트 (최적화된 버전) + * @param cardsetId 카드셋 ID + * @param awareness awareness 데이터 + */ + private broadcastAwareness(cardsetId: string, awareness: Uint8Array): void { + this.sendAwareness(cardsetId, 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(), - }; + /** + * DB에서 문서를 로드하거나 없으면 새로 생성 + * DB에서 로드한 경우 Redis에 저장 + */ + private async loadDocumentFromDBOrCreate( + cardsetId: string, + ): Promise { + const numericCardsetId = Number(cardsetId); - this.logger.log( - `Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`, + if (Number.isNaN(numericCardsetId)) { + this.logger.error( + `Cannot load document from DB for cardset ${cardsetId}: invalid numeric id`, ); - client.emit('access-control', accessControlMessage); + return null; + } - // 클라이언트가 이벤트를 받을 시간을 주기 위해 약간의 지연 - setTimeout(() => { - this.logger.log(`Auth result for client ${client.id}: ${hasAccess}`); - }, 100); + 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.error( - `Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`, + this.logger.warn( + `Failed to load from DB for cardset ${cardsetId}, creating new document: ${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(), - }); } - } - @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(), - }); + // DB에도 없거나 에러 발생 시 새로 생성 + return this.createNewDocument(cardsetId); } - private startHeartbeat(client: Socket) { - const timer = setInterval(() => { - // 클라이언트가 연결되어 있는지 확인 - if (!client.connected) { + /** + * 새 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( - `Client ${client.id} is not connected, stopping heartbeat`, + `Failed to save new document to Redis: ${error}, continuing anyway`, ); - this.stopHeartbeat(client.id); - return; + }); + 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); } + } + } - // 클라이언트에게 heartbeat 전송 - client.emit('heartbeat', { - timestamp: new Date().toISOString(), - }); - }, this.heartbeatInterval); + 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`); + } - this.heartbeatTimers.set(client.id, timer); + private clearScheduledFlush(cardsetId: string) { + const timeout = this.flushTimeouts.get(cardsetId); + if (timeout) { + clearTimeout(timeout); + this.flushTimeouts.delete(cardsetId); + } } - private stopHeartbeat(clientId: string) { - const timer = this.heartbeatTimers.get(clientId); - if (timer) { - clearInterval(timer); - this.heartbeatTimers.delete(clientId); + 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 a6c3327..673a486 100644 --- a/src/websocket/websocket.module.ts +++ b/src/websocket/websocket.module.ts @@ -1,8 +1,18 @@ -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({ - providers: [CollaborationGateway], - exports: [], + imports: [ + AuthModule, + forwardRef(() => CardsetModule), + TypeOrmModule.forFeature([CardsetIncremental]), + ], + providers: [CollaborationGateway, YjsDocumentService], + exports: [YjsDocumentService], }) export class WebSocketModule {} diff --git a/src/websocket/yjs-document.service.spec.ts b/src/websocket/yjs-document.service.spec.ts new file mode 100644 index 0000000..79074c2 --- /dev/null +++ b/src/websocket/yjs-document.service.spec.ts @@ -0,0 +1,50 @@ +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: { + create: jest.Mock; + save: jest.Mock; + }; + + 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 new file mode 100644 index 0000000..66877b8 --- /dev/null +++ b/src/websocket/yjs-document.service.ts @@ -0,0 +1,413 @@ +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, + @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 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'); + } + + // Clean up pending debounce timers + this.flushTimeouts.forEach((timeout) => clearTimeout(timeout)); + this.flushTimeouts.clear(); + } + + /** + * Yjs 문서를 Redis에 저장 + */ + 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, + ); + // Redis 저장 실패해도 에러를 throw하지 않고 계속 진행 + // (문서는 메모리에서 사용 가능) + } + } + + /** + * Redis에서 Yjs 문서를 로드 + */ + 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}`; + 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`, + ); + 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 { + // 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(); + 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); + } + + // 증분 업데이트 내역을 별도 리스트로 보관 + 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, + ); + 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에서 증분 업데이트 리스트 조회 + 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( + `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에서 문서 삭제 + */ + async deleteDocument(cardsetId: string): Promise { + 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( + `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, + ); + } + } +}