From 2ef1c5389faf2659427200b95f921b3315347476 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 05:54:00 +0000 Subject: [PATCH 01/72] feat(be): install websokets, redisAdapter, ioredis for studygroup --- apps/backend/package.json | 6 + pnpm-lock.yaml | 316 +++++++++++++++++++++++++++++++++++--- 2 files changed, 297 insertions(+), 25 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 074d63f3ce..5e1c4a076c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,7 +39,9 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", + "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/swagger": "^11.2.0", + "@nestjs/websockets": "^11.1.17", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", @@ -59,6 +61,7 @@ "@opentelemetry/semantic-conventions": "^1.36.0", "@prisma/client": "^6.13.0", "@prisma/instrumentation": "~6.13.0", + "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.1", "argon2": "^0.44.0", "axios": "^1.11.0", @@ -80,6 +83,7 @@ "graphql-upload": "^17.0.0", "handlebars": "^4.7.8", "he": "^1.2.0", + "ioredis": "^5.10.1", "keyv": "^5.5.0", "nestjs-otel": "^6.2.0", "nestjs-pino": "^4.4.0", @@ -92,9 +96,11 @@ "pino-http": "^10.5.0", "pino-pretty": "^11.3.0", "prisma-graphql-type-decimal": "^3.0.1", + "redis": "^5.11.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.2", "sanitize-filename": "^1.6.3", + "socket.io": "^4.8.3", "sql-formatter": "^15.6.6", "unzipper": "^0.12.3", "web-push": "^3.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3faa7a6d8b..a47c8c049a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 4.0.2(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.6 - version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^3.0.1 version: 3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) @@ -154,9 +154,15 @@ importers: '@nestjs/platform-express': specifier: ^11.1.6 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/platform-socket.io': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) '@nestjs/swagger': specifier: ^11.2.0 version: 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/websockets': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -214,6 +220,9 @@ importers: '@prisma/instrumentation': specifier: ~6.13.0 version: 6.13.0(@opentelemetry/api@1.9.0) + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.6) archiver: specifier: ^7.0.1 version: 7.0.1 @@ -277,6 +286,9 @@ importers: he: specifier: ^1.2.0 version: 1.2.0 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 keyv: specifier: ^5.5.0 version: 5.5.0 @@ -313,6 +325,9 @@ importers: prisma-graphql-type-decimal: specifier: ^3.0.1 version: 3.0.1(@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2))(graphql@16.11.0) + redis: + specifier: ^5.11.0 + version: 5.11.0 reflect-metadata: specifier: ^0.2.1 version: 0.2.2 @@ -322,6 +337,9 @@ importers: sanitize-filename: specifier: ^1.6.3 version: 1.6.3 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 sql-formatter: specifier: ^15.6.6 version: 15.6.6 @@ -957,7 +975,7 @@ packages: '@apollo/server@4.12.2': resolution: {integrity: sha512-jKRlf+sBMMdKYrjMoiWKne42Eb6paBfDOr08KJnUaeaiyWFj+/040FjVPQI7YGLfdwnYIsl1NUUqS2UdgezJDg==} engines: {node: '>=14.16.0'} - deprecated: Apollo Server v4 is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details. + deprecated: Apollo Server v4 is end-of-life since January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details. peerDependencies: graphql: ^16.6.0 @@ -2928,8 +2946,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} @@ -3387,6 +3405,13 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io@11.1.17': + resolution: {integrity: sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/schematics@11.0.7': resolution: {integrity: sha512-t8dNYYMwEeEsrlwc2jbkfwCfXczq4AeNEgx1KVQuJ6wYibXk0ZbXbPdfp8scnEAaQv1grpncNV5gWgzi7ZwbvQ==} peerDependencies: @@ -3422,6 +3447,18 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/websockets@11.1.17': + resolution: {integrity: sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@next/bundle-analyzer@15.4.10': resolution: {integrity: sha512-I+nsSr8eS9n4ggjmFL6UHgbEqb9+TBeRxt6Q5PBxTJYtm9HvJnrAbrLajz/nDdnisqtXrn4qWjCio8CZ9OQgOQ==} @@ -4558,10 +4595,43 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@redis/client@5.8.0': resolution: {integrity: sha512-ywZjKGoSSAECGYOd9bJpws6d4867SN686obUWT/sRmo1c/Q8V+jWyInvlqwKa0BOvTHHwYeB2WFUEvd6PADeOQ==} engines: {node: '>= 18'} + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -5002,6 +5072,15 @@ packages: resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@socket.io/redis-adapter@8.3.0': + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -5581,6 +5660,9 @@ packages: '@types/cookies@0.9.1': resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -6548,6 +6630,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} @@ -7264,6 +7350,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -7545,6 +7640,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -8283,6 +8386,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -8299,20 +8403,23 @@ packages: glob@10.3.12: resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -8646,8 +8753,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.7.0: - resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} ipaddr.js@1.9.1: @@ -10041,6 +10148,9 @@ packages: resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} engines: {node: '>=14.16'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -10086,6 +10196,10 @@ packages: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -11003,6 +11117,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -11421,6 +11539,17 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -11777,6 +11906,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} @@ -12097,6 +12227,10 @@ packages: uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -12432,6 +12566,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -15059,14 +15194,14 @@ snapshots: '@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.21 '@golevelup/nestjs-rabbitmq@5.7.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@golevelup/nestjs-discovery': 4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) amqp-connection-manager: 4.1.14(amqplib@0.10.8) amqplib: 0.10.8 lodash: 4.17.21 @@ -15860,7 +15995,7 @@ snapshots: optionalDependencies: '@types/node': 22.17.1 - '@ioredis/commands@1.3.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/balanced-match@4.0.1': {} @@ -16181,7 +16316,7 @@ snapshots: dependencies: '@css-inline/css-inline': 0.14.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) glob: 10.3.12 nodemailer: 6.10.1 optionalDependencies: @@ -16202,7 +16337,7 @@ snapshots: '@apollo/server': 4.12.2(graphql@16.11.0) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@4.12.2(graphql@16.11.0)) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/graphql': 13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2) graphql: 16.11.0 iterall: 1.3.0 @@ -16218,21 +16353,21 @@ snapshots: '@nestjs/bull-shared@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 '@nestjs/bullmq@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(bullmq@5.58.0)': dependencies: '@nestjs/bull-shared': 11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.58.0 tslib: 2.8.1 '@nestjs/cache-manager@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(cache-manager@7.1.1)(keyv@5.5.0)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cache-manager: 7.1.1 keyv: 5.5.0 rxjs: 7.8.2 @@ -16290,7 +16425,7 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 @@ -16303,11 +16438,12 @@ snapshots: uid: 2.0.2 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 '@nestjs/graphql@13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2)': @@ -16316,7 +16452,7 @@ snapshots: '@graphql-tools/schema': 10.0.23(graphql@16.11.0) '@graphql-tools/utils': 10.8.6(graphql@16.11.0) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) chokidar: 4.0.3 fast-glob: 3.3.3 @@ -16361,7 +16497,7 @@ snapshots: '@nestjs/platform-express@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -16370,6 +16506,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-socket.io@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + socket.io: 4.8.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@nestjs/schematics@11.0.7(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) @@ -16396,7 +16544,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 @@ -16410,11 +16558,23 @@ snapshots: '@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-express@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/websockets@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-socket.io': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) + '@next/bundle-analyzer@15.4.10': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -17583,10 +17743,30 @@ snapshots: dependencies: react: 19.1.1 + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + '@redis/client@5.8.0': dependencies: cluster-key-slot: 1.1.2 + '@redis/json@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + '@remirror/core-constants@3.0.0': {} '@repeaterjs/repeater@3.0.6': {} @@ -18108,6 +18288,17 @@ snapshots: '@smithy/types': 4.3.2 tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.6 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@standard-schema/spec@1.0.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -18709,6 +18900,10 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 22.17.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.17.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -19853,6 +20048,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + base64url@3.0.1: {} big-integer@1.6.52: {} @@ -19982,7 +20179,7 @@ snapshots: bullmq@5.58.0: dependencies: cron-parser: 4.9.0 - ioredis: 5.7.0 + ioredis: 5.10.1 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.2 @@ -20679,6 +20876,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -20935,6 +21136,25 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.17.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -22335,9 +22555,9 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.7.0: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.3.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.1(supports-color@8.1.1) denque: 2.1.0 @@ -23795,7 +24015,7 @@ snapshots: nestjs-otel@6.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6): dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.35.5(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -23930,6 +24150,8 @@ snapshots: normalize-url@8.0.2: {} + notepack.io@3.0.1: {} + npm-run-path@2.0.2: dependencies: path-key: 2.0.1 @@ -24000,6 +24222,8 @@ snapshots: object-hash@2.2.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -25071,6 +25295,16 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -25586,6 +25820,36 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -26277,6 +26541,8 @@ snapshots: uid2@0.0.4: {} + uid2@1.0.0: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 From 8516f75903960ea6035d4cbf6a7fc706b28ad539 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:22:23 +0000 Subject: [PATCH 02/72] feat(be): add Redis module and Socket.IO adapter --- apps/backend/libs/redis/src/index.ts | 2 + .../libs/redis/src/redis-io.adapter.ts | 30 ++++++++++++++ apps/backend/libs/redis/src/redis.module.ts | 41 +++++++++++++++++++ apps/backend/libs/redis/tsconfig.lib.json | 9 ++++ 4 files changed, 82 insertions(+) create mode 100644 apps/backend/libs/redis/src/index.ts create mode 100644 apps/backend/libs/redis/src/redis-io.adapter.ts create mode 100644 apps/backend/libs/redis/src/redis.module.ts create mode 100644 apps/backend/libs/redis/tsconfig.lib.json diff --git a/apps/backend/libs/redis/src/index.ts b/apps/backend/libs/redis/src/index.ts new file mode 100644 index 0000000000..3ae8bfff33 --- /dev/null +++ b/apps/backend/libs/redis/src/index.ts @@ -0,0 +1,2 @@ +export * from './redis.module' +export * from './redis-io.adapter' diff --git a/apps/backend/libs/redis/src/redis-io.adapter.ts b/apps/backend/libs/redis/src/redis-io.adapter.ts new file mode 100644 index 0000000000..a5893af92d --- /dev/null +++ b/apps/backend/libs/redis/src/redis-io.adapter.ts @@ -0,0 +1,30 @@ +import type { INestApplicationContext } from '@nestjs/common' +import { IoAdapter } from '@nestjs/platform-socket.io' +import { createAdapter } from '@socket.io/redis-adapter' +import type Redis from 'ioredis' +import type { ServerOptions } from 'socket.io' + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType + + constructor( + app: INestApplicationContext, + private readonly redisClient: Redis + ) { + super(app) + } + + async connectToRedis() { + const pubClient = this.redisClient + const subClient = pubClient.duplicate() + this.adapterConstructor = createAdapter(pubClient, subClient) + } + + createIOServer(port: number, options?: ServerOptions) { + const server = super.createIOServer(port, options) + if (this.adapterConstructor) { + server.adapter(this.adapterConstructor) + } + return server + } +} diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts new file mode 100644 index 0000000000..57c19a9b86 --- /dev/null +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -0,0 +1,41 @@ +// apps/backend/libs/redis/src/redis.module.ts +import { Global, Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import Redis from 'ioredis' + +export const REDIS_CLIENT = 'REDIS_CLIENT' + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: async (config: ConfigService) => { + const host = config.get('REDIS_HOST') + const port = config.get('REDIS_PORT') + const db = 1 + + if (!host || !port) { + throw new Error('Redis host and port must be configured') + } + + const redis = new Redis({ host, port, db }) + + try { + const result = await redis.ping() + if (result !== 'PONG') { + throw new Error('Unexpected ping response') + } + } catch (error) { + throw new Error(`Redis connection failed: ${error.message}`) + } + + // console.log('✅ Redis connected') + return redis + }, + inject: [ConfigService] + } + ], + exports: [REDIS_CLIENT] +}) +export class RedisModule {} diff --git a/apps/backend/libs/redis/tsconfig.lib.json b/apps/backend/libs/redis/tsconfig.lib.json new file mode 100644 index 0000000000..1f4c603050 --- /dev/null +++ b/apps/backend/libs/redis/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/redis" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} From fb9ddbe727528e2c6825909d315a9faf01772b91 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:24:16 +0000 Subject: [PATCH 03/72] feat(be): add WebSocket auth to JWT auth guard --- .../libs/auth/src/jwt/jwt-auth.guard.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index ab8cb253f7..b84c851af9 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -6,6 +6,7 @@ import { import { Reflector } from '@nestjs/core' import { type GqlContextType, GqlExecutionContext } from '@nestjs/graphql' import { AuthGuard } from '@nestjs/passport' +import { Socket } from 'socket.io' import { AUTH_NOT_NEEDED_KEY, USER_NULL_WHEN_AUTH_FAILED @@ -18,12 +19,19 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } canActivate(context: ExecutionContext) { - const request = this.getRequest(context) const isAuthNotNeeded = this.reflector.getAllAndOverride( AUTH_NOT_NEEDED_KEY, [context.getHandler(), context.getClass()] ) - if (isAuthNotNeeded && !request.query.groupId) { + const request = this.getRequest(context) + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + } + + if (isAuthNotNeeded && !groupId) { return true } return super.canActivate(context) @@ -33,12 +41,30 @@ export class JwtAuthGuard extends AuthGuard('jwt') { if (context.getType() === 'graphql') { return GqlExecutionContext.create(context).getContext().req } + + // ws 컨텍스트 — handshake에서 JWT 토큰 추출 + if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() + const handshake = client.handshake + + if (handshake.auth?.token) { + handshake.headers.authorization = `Bearer ${handshake.auth.token}` + } + + return handshake + } return super.getRequest(context) } handleRequest(err, user, info, context: ExecutionContext) { const request = this.getRequest(context) - const groupId = request.query.groupId + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + } + const userNullWhenAuthFailed = this.reflector.getAllAndOverride( USER_NULL_WHEN_AUTH_FAILED, [context.getHandler(), context.getClass()] From fb3ff4b5845e05fa8d281bd0d49908ecf1838d76 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:30:18 +0000 Subject: [PATCH 04/72] feat(be): integrate Redis module into application --- apps/backend/apps/client/src/app.module.ts | 4 +++- apps/backend/tsconfig.json | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index e7fe311671..55c4b3bccd 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -12,6 +12,7 @@ import { ClientExceptionFilter } from '@libs/exception' import { openTelemetryModuleOption } from '@libs/instrumentation' import { pinoLoggerModuleOption } from '@libs/logger' import { PrismaModule } from '@libs/prisma' +import { RedisModule } from '@libs/redis' import { AnnouncementModule } from './announcement/announcement.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -55,7 +56,8 @@ import { WorkbookModule } from './workbook/workbook.module' NotificationModule, LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot(openTelemetryModuleOption), - StudyModule + StudyModule, + RedisModule ], controllers: [AppController], providers: [ diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9d66c721ea..22d537cdb7 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -31,7 +31,8 @@ "@libs/types": ["./libs/types/src/index.ts"], "@libs/instrumentation": ["./libs/instrumentation.ts"], "@libs/storage": ["./libs/storage/src/index.ts"], - "@libs/amqp": ["./libs/amqp/src/index.ts"] + "@libs/amqp": ["./libs/amqp/src/index.ts"], + "@libs/redis": ["./libs/redis/src/index.ts"] } } } From 2c5c395ef0136a0e83805003f02f22956bb2f997 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:57:34 +0000 Subject: [PATCH 05/72] feat(be): add Redis WebSocket adapter to main --- apps/backend/apps/client/src/main.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/main.ts b/apps/backend/apps/client/src/main.ts index 5655d3ec21..92ff5eeb5d 100644 --- a/apps/backend/apps/client/src/main.ts +++ b/apps/backend/apps/client/src/main.ts @@ -2,11 +2,13 @@ import { ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import cookieParser from 'cookie-parser' +import type Redis from 'ioredis' import Instrumentation from '@libs/instrumentation' +import { REDIS_CLIENT, RedisIoAdapter } from '@libs/redis' /** - TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 - 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 + TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 + 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 자세한 이유는 [이 comment](https://github.com/skkuding/codedang/pull/2705#discussion_r2072945663)를 참고해주세요. */ const bootstrap = async () => { @@ -29,6 +31,13 @@ const bootstrap = async () => { app.useGlobalInterceptors(new LoggerErrorInterceptor()) app.useGlobalPipes(new ValidationPipe({ whitelist: true })) app.use(cookieParser()) + + // Redis WebSocket adapter 연결 + const redisClient = app.get(REDIS_CLIENT) + const redisIoAdapter = new RedisIoAdapter(app, redisClient) + await redisIoAdapter.connectToRedis() // redis 연결 + app.useWebSocketAdapter(redisIoAdapter) // adapter 등록 + if (process.env.APP_ENV !== 'production' && process.env.APP_ENV !== 'stage') { app.enableCors({ origin: 'http://localhost:5525', From 7642979eebe4c80980597a0a5e044c1728f0c01f Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 07:08:23 +0000 Subject: [PATCH 06/72] feat(be): add WebSocket gateway for study group --- .../apps/client/src/study/study.gateway.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/backend/apps/client/src/study/study.gateway.ts diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts new file mode 100644 index 0000000000..10369fdcd3 --- /dev/null +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -0,0 +1,42 @@ +import { Logger } from '@nestjs/common' +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect +} from '@nestjs/websockets' +import type { Server, Socket } from 'socket.io' + +// TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 +@WebSocketGateway({ + namespace: 'study', + cors: { origin: true, credentials: true } +}) +export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server + + private readonly logger = new Logger(StudyGateway.name) + + // ✅ Gateway 초기화 완료 확인용 + // afterInit() { + // this.logger.log('✅ StudyGateway initialized') + // } + + // 클라이언트 연결 + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`) + } + + // 클라이언트 연결 해제 + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`) + } + + // ✅ 연결 확인용 ping + // @SubscribeMessage('ping') + // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { + // this.logger.log(`Ping from ${client.id}`) + // return { event: 'pong', data: '✅ Gateway connected!' } + // } +} From 30bd3a059d7580e89b68f0d94768f2c69d585fcd Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 07:09:06 +0000 Subject: [PATCH 07/72] feat(be): add StudyGateway to StudyModule --- apps/backend/apps/client/src/study/study.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index a926826c84..1272ce873b 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' import { StudyController } from './study.controller' +import { StudyGateway } from './study.gateway' import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService], + providers: [StudyService, StudyGateway], imports: [RolesModule] }) export class StudyModule {} From cc23ef69c081fe90b9f1a52ed7b9bc01d0c8b7b8 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 14:56:52 +0000 Subject: [PATCH 08/72] feat(be): add validateJoinableStudyGroup in study service --- .../apps/client/src/study/study.service.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 823ff038b5..63107d7926 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -441,4 +441,44 @@ export class StudyService { } }) } + + /** + * 사용자가 해당 스터디 그룹 소속인지 검증하고, 회원 정보 반환합니다. + * + * @param {number} groupId 그룹ID + * @param {number} userId 유저ID + * @returns 사용자의 ID, 이름, 리더 여부를 포함한 객체 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 + */ + async validateJoinableStudyGroup(groupId: number, userId: number) { + const membership = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { + userId, + groupId + } + }, + select: { + userId: true, + isGroupLeader: true, + user: { + select: { + username: true + } + } + } + }) + + if (!membership) { + throw new ForbiddenAccessException('User ID is not in the group') + } + + return { + userId: membership.userId, + userName: membership.user.username, + isLeader: membership.isGroupLeader + } + } } From 5273f8b10ebbe2d524e13f827f4e3b9902389d99 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 17:51:47 +0000 Subject: [PATCH 09/72] feat(be): add StudyRoom handleJoin() --- .../study/interface/study-socket.interface.ts | 46 ++++ .../client/src/study/study-room.service.ts | 215 ++++++++++++++++++ .../apps/client/src/study/study.gateway.ts | 60 ++++- .../apps/client/src/study/study.module.ts | 3 +- 4 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 apps/backend/apps/client/src/study/interface/study-socket.interface.ts create mode 100644 apps/backend/apps/client/src/study/study-room.service.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts new file mode 100644 index 0000000000..92a9a99c4b --- /dev/null +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -0,0 +1,46 @@ +// Redis Key +export const ROOM_PREFIX = 'study-room' +export const RECONNECT_PREFIX = 'study-reconnect' + +export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` +export const membersKey = (groupId: number) => + `${ROOM_PREFIX}:${groupId}:members` + +// 상수 +export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 + +// Room +export interface RoomState { + startedAt: number // Unix ms + endAt: number // Unix ms + hostUserId: number +} + +// Member +export interface RoomMember { + userId: number + userName: string + isLeader: boolean + joinedAt: number +} + +// Socket Response +export interface SocketResponse { + success: boolean + message?: string + data?: T + code?: string +} + +export interface JoinResponse { + members: RoomMember[] + startedAt: number + endAt: number + remainMs: number + hostUserId: number +} + +// PayLoad +export interface JoinPayload { + groupId: number +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts new file mode 100644 index 0000000000..460aa4a11a --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -0,0 +1,215 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type Redis from 'ioredis' +import type { Server, Socket } from 'socket.io' +import { REDIS_CLIENT } from '@libs/redis' +import { + roomKey, + membersKey, + SESSION_DURATION_MS, + type RoomState, + type RoomMember, + type JoinResponse, + type SocketResponse +} from './interface/study-socket.interface' +import { StudyService } from './study.service' + +@Injectable() +export class StudyRoomService { + private readonly logger = new Logger(StudyRoomService.name) + private server: Server + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly studyService: StudyService + ) {} + + setServer(server: Server): void { + this.server = server + } + + /** + * 사용자 WebSocket을 스터디 룸에 입장시킨다. + * + * 1. 사용자의 그룹 참여 권한 검증 + * 2. 재연결 복구 확인 + * 3. socket.data에 사용자 메타데이터 저장하고 room에 join() + * 4. Redis에서 룸 상태 생성 또는 조회 + * 5. 세션 종료 여부 검증 + * 6. Redis에 멤버 등록하고 전체 멤버 조회 + * 7. 첫번째 입장이면 room:started 이벤트 브로드케스트 + * 그외 입장이면 room:participantsChanged 이벤트 emit + * 8. 클라이언트에 현재 룸 상태 반환 + * + * @param client 연결된 Socket 인스턴스 + * @param groupId 스터디 그룹 ID + * @returns 현재 룸 상태 + */ + async join( + client: Socket, + groupId: number + ): Promise> { + const userId: number = client.data.userId + const membership = await this.studyService.validateJoinableStudyGroup( + groupId, + userId + ) + + // TODO: reconnect 확인 + + client.data = { + userId, + userName: membership.userName, + isLeader: membership.isLeader, + groupId + } + client.join(roomKey(groupId)) + + const now = Date.now() + const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) + + if (!state || now >= state.endAt) { + client.leave(roomKey(groupId)) + return { success: false, message: '세션이 이미 종료되었습니다.' } + } + + await this.addMember(groupId, client.id, { + userId, + userName: membership.userName, + isLeader: membership.isLeader, + joinedAt: now + }) + + const members = await this.getMembers(groupId) + + if (isFirst) this.onFirstJoin(groupId, state, members) + else this.onSubsequentJoin(client, groupId, members) + + return { + success: true, + data: { + members, + startedAt: state.startedAt, + endAt: state.endAt, + remainMs: state.endAt - now, + hostUserId: state.hostUserId + } + } + } + + /** + * 스터디 룸을 생성하거나 기존 상태를 조회한다. + * + * 1. 현재 시간을 기준으로 RoomState 상태를 생성 + * 2. Redis에 SET NX로 roomKey 저장 시도 + * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 + * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 + * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 + * + * @param groupId 스터디 그룹 ID + * @param userId 현재 사용자 ID + * @param now 현재 시간 + * @returns 룸 상태값, 사용자의 입장이 첫번째인지 + */ + private async initOrGetRoom( + groupId: number, + userId: number, + now: number + ): Promise<{ state: RoomState | null; isFirst: boolean }> { + const activeState: RoomState = { + startedAt: now, + endAt: now + SESSION_DURATION_MS, + hostUserId: userId + } + + const isFirst = !!(await this.redis.set( + roomKey(groupId), + JSON.stringify(activeState), + 'NX' + )) + + // isFirst가 ok이면 첫번째 / null이면 이미 룸 존재 + const state = isFirst ? activeState : await this.getRoomState(groupId) + return { state, isFirst } + } + + /** + * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. + * + * @param groupId 스터디 그룹 ID + * @param state 룸 상태 + * @param members 현재 참여자 목록 + */ + private async onFirstJoin( + groupId: number, + state: RoomState, + members: RoomMember[] + ): Promise { + // 첫 번째: 타이머 시작 + 룸 전체(본인 포함)에 started emit + + // TODO: await this.scheduleJobs() + + this.server.to(roomKey(groupId)).emit('room:started', { + startedAt: state.startedAt, + endAt: state.endAt, + members + }) + + this.logger.log( + `room start🚀 : groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` + ) + } + + /** + * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. + * + * @param client 현재 소켓 + * @param groupId 스터디 그룹 ID + * @param members 최신 참여자 목록 + */ + private onSubsequentJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) + } + + // Redis + /** + * Redis에서 룸 상태를 조회합니다. + * + * @param groupId 스터디 그룹 ID + * @returns 룸 상태값 | null + */ + private async getRoomState(groupId: number): Promise { + const raw = await this.redis.get(roomKey(groupId)) + return raw ? (JSON.parse(raw) as RoomState) : null + } + + /** + * Redis Hash에 소켓 기준으로 멤버를 추가합니다. + * + * @param groupId 스터디 그룹 ID + * @param socketId Socket ID + * @param member 저장할 멤버 정보 + */ + private async addMember( + groupId: number, + socketId: string, + member: RoomMember + ): Promise { + await this.redis.hset(membersKey(groupId), socketId, JSON.stringify(member)) + } + + /** + * Redis Hash에 저장된 모든 멤버를 조회합니다. + * + * @param groupId 스터디 그룹 ID + * @returns 스터디 룸에 있는 모든 멤버들 + */ + async getMembers(groupId: number): Promise { + const raw = await this.redis.hgetall(membersKey(groupId)) + if (!raw) return [] + return Object.values(raw).map((v) => JSON.parse(v) as RoomMember) + } +} diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 10369fdcd3..c4541175ac 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -1,31 +1,48 @@ -import { Logger } from '@nestjs/common' +import { Logger, UseGuards } from '@nestjs/common' import { WebSocketGateway, WebSocketServer, + OnGatewayInit, OnGatewayConnection, - OnGatewayDisconnect + OnGatewayDisconnect, + SubscribeMessage, + ConnectedSocket, + MessageBody, + WsException } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' +import { JwtAuthGuard } from '@libs/auth' +import type { JoinPayload } from './interface/study-socket.interface' +import { StudyRoomService } from './study-room.service' -// TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 +@UseGuards(JwtAuthGuard) @WebSocketGateway({ namespace: 'study', - cors: { origin: true, credentials: true } + cors: { + // TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 + // origin: ['ws://localhost:3002/api/room'] + origin: true, + credentials: true + } }) -export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { +export class StudyGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ @WebSocketServer() server: Server private readonly logger = new Logger(StudyGateway.name) - // ✅ Gateway 초기화 완료 확인용 - // afterInit() { - // this.logger.log('✅ StudyGateway initialized') - // } + constructor(private readonly studyRoomService: StudyRoomService) {} + + afterInit(server: Server) { + this.studyRoomService.setServer(server) + this.logger.log('✅ StudyGateway initialized') + } // 클라이언트 연결 handleConnection(client: Socket) { - this.logger.log(`Client connected: ${client.id}`) + this.logger.log(`🔥 Client connected: ${client.id}`) } // 클라이언트 연결 해제 @@ -33,10 +50,33 @@ export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { this.logger.log(`Client disconnected: ${client.id}`) } + // Room + @SubscribeMessage('room:join') + async handleJoin( + @ConnectedSocket() client: Socket, + @MessageBody() payload: JoinPayload + ) { + console.log('🔥 join 들어옴') + + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(payload?.groupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + + return this.studyRoomService.join(client, groupId) + } + // ✅ 연결 확인용 ping // @SubscribeMessage('ping') // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { // this.logger.log(`Ping from ${client.id}`) // return { event: 'pong', data: '✅ Gateway connected!' } // } + + private parsePositiveInt(value: unknown): number | null { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num + } } diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index 1272ce873b..57651dc950 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { StudyRoomService } from './study-room.service' import { StudyController } from './study.controller' import { StudyGateway } from './study.gateway' import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService, StudyGateway], + providers: [StudyService, StudyGateway, StudyRoomService], imports: [RolesModule] }) export class StudyModule {} From 71f6769231ddec3b4951f47686294a26dca44c56 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 17:53:12 +0000 Subject: [PATCH 10/72] fix(be): add user data handling logic to websocket handler --- apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index b84c851af9..d41bebfa5b 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -61,8 +61,19 @@ export class JwtAuthGuard extends AuthGuard('jwt') { let groupId = request.query?.groupId if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() const data = context.switchToWs().getData() groupId = data?.groupId ?? groupId + + if (err || !user) { + throw err || new UnauthorizedException() + } + client.data.user = user + client.data.userId = user.id ?? user.userId + client.data.userName = user.username + client.data.userRole = user.role ?? user.userRole + + return user } const userNullWhenAuthFailed = this.reflector.getAllAndOverride( From 0d3dc42c464313ac16bd7cab6dccf3de16c7720d Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:06:14 +0000 Subject: [PATCH 11/72] fix(be): map user id instead of socket id --- .../apps/client/src/study/study-room.service.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 460aa4a11a..aee009eb44 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -72,7 +72,7 @@ export class StudyRoomService { return { success: false, message: '세션이 이미 종료되었습니다.' } } - await this.addMember(groupId, client.id, { + await this.addMember(groupId, userId, { userId, userName: membership.userName, isLeader: membership.isLeader, @@ -190,15 +190,19 @@ export class StudyRoomService { * Redis Hash에 소켓 기준으로 멤버를 추가합니다. * * @param groupId 스터디 그룹 ID - * @param socketId Socket ID + * @param userId 사용자 ID * @param member 저장할 멤버 정보 */ private async addMember( groupId: number, - socketId: string, + userId: number, member: RoomMember ): Promise { - await this.redis.hset(membersKey(groupId), socketId, JSON.stringify(member)) + await this.redis.hset( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) } /** From 3840feba531f95bbd37ea683d924bb22cce73632 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:09:42 +0000 Subject: [PATCH 12/72] fix(be): fix spread to preserve data --- apps/backend/apps/client/src/study/study-room.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index aee009eb44..19222db1a9 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -57,7 +57,7 @@ export class StudyRoomService { // TODO: reconnect 확인 client.data = { - userId, + ...client.data, userName: membership.userName, isLeader: membership.isLeader, groupId From 98da048065a42c675f85b049cbbb71190b8fa1f8 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:20:53 +0000 Subject: [PATCH 13/72] fix(be): add params type --- .../client/src/study/study-room.service.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 19222db1a9..992f9ef0f1 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -40,8 +40,8 @@ export class StudyRoomService { * 그외 입장이면 room:participantsChanged 이벤트 emit * 8. 클라이언트에 현재 룸 상태 반환 * - * @param client 연결된 Socket 인스턴스 - * @param groupId 스터디 그룹 ID + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID * @returns 현재 룸 상태 */ async join( @@ -67,7 +67,11 @@ export class StudyRoomService { const now = Date.now() const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) - if (!state || now >= state.endAt) { + if (!state) { + client.leave(roomKey(groupId)) + return { success: false, message: '룸 상태 가져오지 못했습니다.' } + } + if (now >= state.endAt) { client.leave(roomKey(groupId)) return { success: false, message: '세션이 이미 종료되었습니다.' } } @@ -105,9 +109,9 @@ export class StudyRoomService { * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 * - * @param groupId 스터디 그룹 ID - * @param userId 현재 사용자 ID - * @param now 현재 시간 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 현재 사용자 ID + * @param {number} now 현재 시간 * @returns 룸 상태값, 사용자의 입장이 첫번째인지 */ private async initOrGetRoom( @@ -135,9 +139,9 @@ export class StudyRoomService { /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * - * @param groupId 스터디 그룹 ID - * @param state 룸 상태 - * @param members 현재 참여자 목록 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomState} state 룸 상태 + * @param {RoomMember[]} members 현재 참여자 목록 */ private async onFirstJoin( groupId: number, @@ -162,9 +166,9 @@ export class StudyRoomService { /** * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. * - * @param client 현재 소켓 - * @param groupId 스터디 그룹 ID - * @param members 최신 참여자 목록 + * @param {Socket} client 현재 소켓 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 */ private onSubsequentJoin( client: Socket, @@ -178,7 +182,7 @@ export class StudyRoomService { /** * Redis에서 룸 상태를 조회합니다. * - * @param groupId 스터디 그룹 ID + * @param {number} groupId 스터디 그룹 ID * @returns 룸 상태값 | null */ private async getRoomState(groupId: number): Promise { @@ -189,9 +193,9 @@ export class StudyRoomService { /** * Redis Hash에 소켓 기준으로 멤버를 추가합니다. * - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID - * @param member 저장할 멤버 정보 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @param {RoomMember} member 저장할 멤버 정보 */ private async addMember( groupId: number, @@ -208,7 +212,7 @@ export class StudyRoomService { /** * Redis Hash에 저장된 모든 멤버를 조회합니다. * - * @param groupId 스터디 그룹 ID + * @param {number} groupId 스터디 그룹 ID * @returns 스터디 룸에 있는 모든 멤버들 */ async getMembers(groupId: number): Promise { From 8b003fcdb2a012de2ddc3d0f426617c7cfb6d6d0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 19:04:15 +0000 Subject: [PATCH 14/72] feat(be): add StudyRoom handleLeave() --- .../study/interface/study-socket.interface.ts | 5 ++ .../client/src/study/study-room.service.ts | 46 +++++++++++++++++-- .../apps/client/src/study/study.gateway.ts | 27 +++++++++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 92a9a99c4b..df496d17a3 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -19,6 +19,7 @@ export interface RoomState { // Member export interface RoomMember { userId: number + socketId: string userName: string isLeader: boolean joinedAt: number @@ -44,3 +45,7 @@ export interface JoinResponse { export interface JoinPayload { groupId: number } + +export interface LeavePayload { + groupId: number +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 992f9ef0f1..64ab835e0c 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -78,6 +78,7 @@ export class StudyRoomService { await this.addMember(groupId, userId, { userId, + socketId: client.id, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now @@ -85,7 +86,7 @@ export class StudyRoomService { const members = await this.getMembers(groupId) - if (isFirst) this.onFirstJoin(groupId, state, members) + if (isFirst) await this.onFirstJoin(groupId, state, members) else this.onSubsequentJoin(client, groupId, members) return { @@ -131,7 +132,6 @@ export class StudyRoomService { 'NX' )) - // isFirst가 ok이면 첫번째 / null이면 이미 룸 존재 const state = isFirst ? activeState : await this.getRoomState(groupId) return { state, isFirst } } @@ -178,6 +178,34 @@ export class StudyRoomService { client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) } + /** + * 사용자가 스터디 룸을 정상적으로 퇴장합니다. + * + * 1. client.data에서 groupId를 초기화 + * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 + * 3. Socket.io 룸에서 클라이언트를 제외(leave) + * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * + * @param {Socket} client 현재 소켓 + * @param {number} groupId 스터디 그룹 ID + * @returns 퇴장 성공 여부 + */ + async leave(client: Socket, groupId: number): Promise { + const { userId } = client.data + + client.data.groupId = undefined + + await this.removeMember(groupId, userId) + client.leave(roomKey(groupId)) + + this.server.to(roomKey(groupId)).emit('room:participantsChanged', { + members: await this.getMembers(groupId) + }) + + this.logger.log(`leave 👋 : userId=${userId}, groupId=${groupId}`) + return { success: true } + } + // Redis /** * Redis에서 룸 상태를 조회합니다. @@ -191,7 +219,7 @@ export class StudyRoomService { } /** - * Redis Hash에 소켓 기준으로 멤버를 추가합니다. + * Redis Hash에 멤버 정보를 사용자 ID를 키로 하여 추가합니다. * * @param {number} groupId 스터디 그룹 ID * @param {number} userId 사용자 ID @@ -210,7 +238,17 @@ export class StudyRoomService { } /** - * Redis Hash에 저장된 모든 멤버를 조회합니다. + * Redis Hash에서 특정 사용자의 멤버 정보를 삭제합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + */ + private async removeMember(groupId: number, userId: number): Promise { + await this.redis.hdel(membersKey(groupId), String(userId)) + } + + /** + * Redis Hash에 저장된 해당 스터디 룸의 모든 멤버를 조회합니다. * * @param {number} groupId 스터디 그룹 ID * @returns 스터디 룸에 있는 모든 멤버들 diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index c4541175ac..38dacbe708 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -12,7 +12,10 @@ import { } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' import { JwtAuthGuard } from '@libs/auth' -import type { JoinPayload } from './interface/study-socket.interface' +import type { + JoinPayload, + LeavePayload +} from './interface/study-socket.interface' import { StudyRoomService } from './study-room.service' @UseGuards(JwtAuthGuard) @@ -42,12 +45,12 @@ export class StudyGateway // 클라이언트 연결 handleConnection(client: Socket) { - this.logger.log(`🔥 Client connected: ${client.id}`) + this.logger.log(`Client connected: socketId=${client.id}`) } // 클라이언트 연결 해제 handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: ${client.id}`) + this.logger.log(`Client disconnected: socketId=${client.id}`) } // Room @@ -56,17 +59,31 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - console.log('🔥 join 들어옴') - const userId = client.data.user?.id ?? client.data.userId if (!userId) throw new WsException('Unauthorized') + console.log(`🔥 room:join 시작: userId=${userId}`) + const groupId = this.parsePositiveInt(payload?.groupId) if (!groupId) throw new WsException('groupId must be a positive integer') return this.studyRoomService.join(client, groupId) } + @SubscribeMessage('room:leave') + async handleLeave( + @ConnectedSocket() client: Socket, + @MessageBody() payload: LeavePayload + ) { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(payload?.groupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + console.log(`🔥 room:leave 시작: userId=${client.data.userId}`) + return this.studyRoomService.leave(client, groupId) + } + // ✅ 연결 확인용 ping // @SubscribeMessage('ping') // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { From c3c5dac76d07b16c73b96a2e8aa1cc1963290cb5 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 04:13:49 +0000 Subject: [PATCH 15/72] refactor(be): separate parsePayload logic --- .../apps/client/src/study/study.gateway.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 38dacbe708..14a25874eb 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -59,14 +59,8 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const userId = client.data.user?.id ?? client.data.userId - if (!userId) throw new WsException('Unauthorized') - - console.log(`🔥 room:join 시작: userId=${userId}`) - - const groupId = this.parsePositiveInt(payload?.groupId) - if (!groupId) throw new WsException('groupId must be a positive integer') - + const { userId, groupId } = this.parsePayload(client, payload?.groupId) + console.log(`🏠room:join 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.join(client, groupId) } @@ -75,12 +69,8 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: LeavePayload ) { - const userId = client.data.user?.id ?? client.data.userId - if (!userId) throw new WsException('Unauthorized') - - const groupId = this.parsePositiveInt(payload?.groupId) - if (!groupId) throw new WsException('groupId must be a positive integer') - console.log(`🔥 room:leave 시작: userId=${client.data.userId}`) + const { userId, groupId } = this.parsePayload(client, payload?.groupId) + console.log(`🏠room:leave 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.leave(client, groupId) } @@ -91,6 +81,19 @@ export class StudyGateway // return { event: 'pong', data: '✅ Gateway connected!' } // } + private parsePayload( + client: Socket, + rawGroupId: unknown + ): { userId: number; groupId: number } { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(rawGroupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + + return { userId, groupId } + } + private parsePositiveInt(value: unknown): number | null { const num = Number(value) if (!Number.isInteger(num) || num <= 0) return null From d81ac43d3244bde05e0b64248445147361a0074d Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 05:41:05 +0000 Subject: [PATCH 16/72] feat(be): setup BullMQ for study-room --- apps/backend/apps/client/src/app.module.ts | 16 ++++++++++++++-- .../apps/client/src/study/study.module.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index 55c4b3bccd..2cf13452e0 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -1,7 +1,8 @@ import { MailerModule } from '@nestjs-modules/mailer' +import { BullModule } from '@nestjs/bullmq' import { CacheModule } from '@nestjs/cache-manager' import { Module, type OnApplicationBootstrap } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_FILTER, APP_GUARD, HttpAdapterHost } from '@nestjs/core' import type { Server } from 'http' import { OpenTelemetryModule } from 'nestjs-otel' @@ -57,7 +58,18 @@ import { WorkbookModule } from './workbook/workbook.module' LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot(openTelemetryModuleOption), StudyModule, - RedisModule + RedisModule, + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + connection: { + host: config.get('REDIS_HOST'), + port: config.get('REDIS_PORT'), + db: 1 + }, + prefix: 'bull-client' + }) + }) ], controllers: [AppController], providers: [ diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index 57651dc950..6a56bd44c4 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,5 +1,8 @@ +import { BullModule } from '@nestjs/bullmq' import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { StudyRoomProcessor } from './study-room.processor' +import { STUDY_ROOM_QUEUE } from './study-room.service' import { StudyRoomService } from './study-room.service' import { StudyController } from './study.controller' import { StudyGateway } from './study.gateway' @@ -7,7 +10,10 @@ import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService, StudyGateway, StudyRoomService], - imports: [RolesModule] + providers: [StudyService, StudyGateway, StudyRoomService, StudyRoomProcessor], + imports: [ + RolesModule, + BullModule.registerQueue({ name: STUDY_ROOM_QUEUE }) // 'study-room + ] }) export class StudyModule {} From d4b8eafc0235e62256e66a24baae1a265fc9b2e0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 17:20:40 +0000 Subject: [PATCH 17/72] feat(be): implement socket disconnect and reconnect recovery logic --- .../study/interface/study-socket.interface.ts | 7 +- .../client/src/study/study-room.processor.ts | 30 +++ .../client/src/study/study-room.service.ts | 247 ++++++++++++++---- .../apps/client/src/study/study.gateway.ts | 3 +- 4 files changed, 238 insertions(+), 49 deletions(-) create mode 100644 apps/backend/apps/client/src/study/study-room.processor.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index df496d17a3..11a2c9f8d1 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -5,9 +5,12 @@ export const RECONNECT_PREFIX = 'study-reconnect' export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` export const membersKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}:members` +export const reconnectKey = (groupId: number, userId: number) => + `${RECONNECT_PREFIX}:${groupId}:${userId}` // 상수 export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 +export const RECONNECT_GRACE_SEC = 30 // 20초 // Room export interface RoomState { @@ -45,7 +48,3 @@ export interface JoinResponse { export interface JoinPayload { groupId: number } - -export interface LeavePayload { - groupId: number -} diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts new file mode 100644 index 0000000000..084109ae80 --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -0,0 +1,30 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { Job } from 'bullmq' +import { + StudyRoomService, + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE +} from './study-room.service' + +@Processor(STUDY_ROOM_QUEUE) +export class StudyRoomProcessor extends WorkerHost { + private readonly logger = new Logger(StudyRoomProcessor.name) + + constructor(private readonly studyRoomService: StudyRoomService) { + super() + } + + async process(job: Job): Promise { + switch (job.name) { + case JOB_RECONNECT_EXPIRE: + await this.studyRoomService.handleReconnectExpiry(job.data) + break + default: + this.logger.warn( + `Unknown job name: ${job.name}`, + JSON.stringify(job.data) + ) + } + } +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 64ab835e0c..cc2dfe0462 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -1,11 +1,15 @@ +import { InjectQueue } from '@nestjs/bullmq' import { Inject, Injectable, Logger } from '@nestjs/common' +import type { Queue } from 'bullmq' import type Redis from 'ioredis' import type { Server, Socket } from 'socket.io' import { REDIS_CLIENT } from '@libs/redis' import { roomKey, + reconnectKey, membersKey, SESSION_DURATION_MS, + RECONNECT_GRACE_SEC, type RoomState, type RoomMember, type JoinResponse, @@ -13,13 +17,17 @@ import { } from './interface/study-socket.interface' import { StudyService } from './study.service' +export const STUDY_ROOM_QUEUE = 'study-room' +export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' + @Injectable() export class StudyRoomService { private readonly logger = new Logger(StudyRoomService.name) - private server: Server + private server!: Server constructor( @Inject(REDIS_CLIENT) private readonly redis: Redis, + @InjectQueue(STUDY_ROOM_QUEUE) private readonly queue: Queue, private readonly studyService: StudyService ) {} @@ -28,65 +36,52 @@ export class StudyRoomService { } /** - * 사용자 WebSocket을 스터디 룸에 입장시킨다. + * 사용자 WebSocket을 스터디 룸에 입장시킵니다. * - * 1. 사용자의 그룹 참여 권한 검증 - * 2. 재연결 복구 확인 - * 3. socket.data에 사용자 메타데이터 저장하고 room에 join() - * 4. Redis에서 룸 상태 생성 또는 조회 - * 5. 세션 종료 여부 검증 - * 6. Redis에 멤버 등록하고 전체 멤버 조회 - * 7. 첫번째 입장이면 room:started 이벤트 브로드케스트 - * 그외 입장이면 room:participantsChanged 이벤트 emit - * 8. 클라이언트에 현재 룸 상태 반환 + * 1. Redis 기준으로 사용자의 그룹 입장 가능 여부 확인 + * 2. 사용자의 스터디 그룹 참여 권한 검증 + * 3. Redis에서 룸 상태 생성 또는 기존 상태 조회하고 유효성 검증 + * 4. Socket.IO 룸 입장 및 Redis 멤버 정보 등록 + * 5. 입장 케이스에 따라 이벤트 emit + * - 첫 번째 입장이면 room:started 이벤트 브로드캐스트 + * - 재연결 입장이면 room:participantReconnected 이벤트 emit + * - 그 외 입장이면 room:participantsChanged 이벤트 emit + * 6. 클라이언트에 현재 룸 상태 반환 * * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID - * @returns 현재 룸 상태 + * @returns 입장 처리 결과와 현재 룸 상태 */ async join( client: Socket, groupId: number ): Promise> { const userId: number = client.data.userId + + const check = await this.checkJoinable(groupId, userId) + if (!check.ok) + return { success: false, code: check.code, message: check.message } + const membership = await this.studyService.validateJoinableStudyGroup( groupId, userId ) - // TODO: reconnect 확인 - - client.data = { - ...client.data, - userName: membership.userName, - isLeader: membership.isLeader, - groupId - } - client.join(roomKey(groupId)) - const now = Date.now() const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) - if (!state) { - client.leave(roomKey(groupId)) + if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - } - if (now >= state.endAt) { - client.leave(roomKey(groupId)) + if (state && now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } - } - await this.addMember(groupId, userId, { - userId, - socketId: client.id, - userName: membership.userName, - isLeader: membership.isLeader, - joinedAt: now - }) + const isRecovered = check.isRecovered + await this.enterRoom(client, groupId, userId, membership, now, isRecovered!) const members = await this.getMembers(groupId) if (isFirst) await this.onFirstJoin(groupId, state, members) + else if (isRecovered) this.onReconnectJoin(client, groupId, members) else this.onSubsequentJoin(client, groupId, members) return { @@ -102,12 +97,44 @@ export class StudyRoomService { } /** - * 스터디 룸을 생성하거나 기존 상태를 조회한다. + * 사용자가 해당 스터디 그룹 룸에 입장 가능한지 확인합니다. + * + * Redis의 멤버 목록과 reconnectKey 존재 여부를 조회하여 + * 이미 멤버로 등록되어 있으면서 재연결 대기 상태가 아니면 중복 입장으로 판단합니다. + * + * @param groupId 스터디 그룹 ID + * @param userId 사용자 ID + * @returns 입장 가능 여부와 재연결 여부 + */ + private async checkJoinable( + groupId: number, + userId: number + ): Promise<{ + ok: boolean + isRecovered?: boolean + code?: string + message?: string + }> { + const [existingMember, reconnectRaw] = await Promise.all([ + this.redis.hget(membersKey(groupId), String(userId)), + this.redis.get(reconnectKey(groupId, userId)) + ]) + + const isRecovered = !!reconnectRaw + + if (existingMember && !isRecovered) + return { ok: false, message: '이미 이 룸에 참여 중입니다.' } + + return { ok: true, isRecovered } + } + + /** + * 스터디 룸을 생성하거나 기존 상태를 조회합니다. * * 1. 현재 시간을 기준으로 RoomState 상태를 생성 * 2. Redis에 SET NX로 roomKey 저장 시도 * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 - * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 + * - 성공이면, 첫 번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 * * @param {number} groupId 스터디 그룹 ID @@ -129,6 +156,8 @@ export class StudyRoomService { const isFirst = !!(await this.redis.set( roomKey(groupId), JSON.stringify(activeState), + 'PX', + SESSION_DURATION_MS + 60_000, 'NX' )) @@ -136,6 +165,52 @@ export class StudyRoomService { return { state, isFirst } } + /** + * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. + * + * 1. 재연결 입장인 경우 Redis의 재연결 대기 키를 삭제하고 예약되어 있던 재연결 만료 작업 취소 + * 2. socket.data에 사용자 메타데이터를 저장 + * 3. Socket.IO 룸에 참여 + * 4. Redis 멤버 목록에 현재 소켓 정보 등록 + * + * @param client 연결된 Socket 인스턴스 + * @param groupId 스터디 그룹 ID + * @param userId 사용자 ID + * @param membership 사용자 이름과 리더 여부 + * @param now 입장 시각(ms) + * @param isRecovered 재연결 복구 여부 + */ + private async enterRoom( + client: Socket, + groupId: number, + userId: number, + membership: { userName: string; isLeader: boolean }, + now: number, + isRecovered: boolean + ): Promise { + if (isRecovered) { + await this.redis.del(reconnectKey(groupId, userId)) + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() + } + client.data = { + ...client.data, + userName: membership.userName, + isLeader: membership.isLeader, + groupId + } + client.join(roomKey(groupId)) + + await this.addMember(groupId, userId, { + userId, + socketId: client.id, + userName: membership.userName, + isLeader: membership.isLeader, + joinedAt: now + }) + } /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * @@ -157,16 +232,12 @@ export class StudyRoomService { endAt: state.endAt, members }) - - this.logger.log( - `room start🚀 : groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` - ) } /** * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. * - * @param {Socket} client 현재 소켓 + * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @param {RoomMember[]} members 최신 참여자 목록 */ @@ -178,6 +249,24 @@ export class StudyRoomService { client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) } + /** + * 재연결된 사용자가 들어왔을 때, 기존 참여자들에게 재연결 완료 사실을 알립니다. + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 + */ + private onReconnectJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + this.server.to(roomKey(groupId)).emit('room:participantReconnected', { + userId: client.data.userId, + userName: client.data.userName, + members + }) + } /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * @@ -186,7 +275,7 @@ export class StudyRoomService { * 3. Socket.io 룸에서 클라이언트를 제외(leave) * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * - * @param {Socket} client 현재 소켓 + * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @returns 퇴장 성공 여부 */ @@ -202,10 +291,80 @@ export class StudyRoomService { members: await this.getMembers(groupId) }) - this.logger.log(`leave 👋 : userId=${userId}, groupId=${groupId}`) return { success: true } } + /** + * 웹소켓 연결이 끊어졌을 때 호출되며, 재연결 유예 기간(Grace Period)을 부여합니다. + * + * 1. Redis에 재연결 대기 상태 키 저장 + * 2. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 + * 3. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 + * + * @param {Socket} client 연결된 Socket 인스턴스 + */ + async handleDisconnect(client: Socket): Promise { + const { userId, groupId, userName } = client.data ?? {} + if (!userId || !groupId) return + + await this.redis.set( + reconnectKey(groupId, userId), + '1', + 'EX', + RECONNECT_GRACE_SEC + 10 + ) + + const jobId = `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + const existingJob = await this.queue.getJob(jobId) + await existingJob?.remove() + + await this.queue.add( + JOB_RECONNECT_EXPIRE, + { userId, groupId, userName }, + { + jobId, + delay: RECONNECT_GRACE_SEC * 1000, + removeOnComplete: true, + removeOnFail: true + } + ) + + this.server + .to(roomKey(groupId)) + .emit('room:participantReconnecting', { userId, userName }) + } + + /** + * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. + * BullMQ Processor에서 호출됩니다. + * + * 1. Redis에서 재연결 대기 상태 키 확인 + * 2. 키가 없으면 이미 복구된 것으로 보고 중단 + * 3. 대기 상태 키와 멤버 정보를 삭제 + * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * + * @param payload + * @param {number} payload.userId 사용자 ID + * @param {number} payload.groupId 스터디 그룹 ID + */ + async handleReconnectExpiry(payload: { + userId: number + groupId: number + }): Promise { + const { userId, groupId } = payload + + const stillDisconnected = await this.redis.get( + reconnectKey(groupId, userId) + ) + if (!stillDisconnected) return + await this.redis.del(reconnectKey(groupId, userId)) + await this.removeMember(groupId, userId) + + this.server.to(roomKey(groupId)).emit('room:participantsChanged', { + members: await this.getMembers(groupId) + }) + } + // Redis /** * Redis에서 룸 상태를 조회합니다. diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 14a25874eb..c8354b2d4c 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -49,8 +49,9 @@ export class StudyGateway } // 클라이언트 연결 해제 - handleDisconnect(client: Socket) { + async handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: socketId=${client.id}`) + await this.studyRoomService.handleDisconnect(client) } // Room From d064f726f9280815cd4d17fcf2635bbe2e770be0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 17:54:11 +0000 Subject: [PATCH 18/72] refactor(be): use socket data instead of payload for room:leave --- .../apps/client/src/study/study.gateway.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index c8354b2d4c..0c524fde33 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -12,10 +12,7 @@ import { } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' import { JwtAuthGuard } from '@libs/auth' -import type { - JoinPayload, - LeavePayload -} from './interface/study-socket.interface' +import type { JoinPayload } from './interface/study-socket.interface' import { StudyRoomService } from './study-room.service' @UseGuards(JwtAuthGuard) @@ -61,17 +58,16 @@ export class StudyGateway @MessageBody() payload: JoinPayload ) { const { userId, groupId } = this.parsePayload(client, payload?.groupId) - console.log(`🏠room:join 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.join(client, groupId) } @SubscribeMessage('room:leave') - async handleLeave( - @ConnectedSocket() client: Socket, - @MessageBody() payload: LeavePayload - ) { - const { userId, groupId } = this.parsePayload(client, payload?.groupId) - console.log(`🏠room:leave 시작: userId=${userId}, groupId=${groupId}`) + async handleLeave(@ConnectedSocket() client: Socket) { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + const groupId = client.data.groupId + if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') + return this.studyRoomService.leave(client, groupId) } From a0bbb1466db21455088c31f83b909a26e71797a4 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 20:26:03 +0000 Subject: [PATCH 19/72] feat(be): implement room lifecycle handling with BullMQ --- .../study/interface/study-socket.interface.ts | 5 +- .../client/src/study/study-room.processor.ts | 13 +- .../client/src/study/study-room.service.ts | 132 ++++++++++++++++-- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 11a2c9f8d1..d323b61041 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -10,7 +10,9 @@ export const reconnectKey = (groupId: number, userId: number) => // 상수 export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 -export const RECONNECT_GRACE_SEC = 30 // 20초 +export const RECONNECT_GRACE_SEC = 30 // 30초 +export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 +export const BUFFER_SEC = 10 // 10초 // Room export interface RoomState { @@ -19,7 +21,6 @@ export interface RoomState { hostUserId: number } -// Member export interface RoomMember { userId: number socketId: string diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts index 084109ae80..56dddcfaab 100644 --- a/apps/backend/apps/client/src/study/study-room.processor.ts +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -4,7 +4,9 @@ import { Job } from 'bullmq' import { StudyRoomService, STUDY_ROOM_QUEUE, - JOB_RECONNECT_EXPIRE + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END } from './study-room.service' @Processor(STUDY_ROOM_QUEUE) @@ -17,9 +19,18 @@ export class StudyRoomProcessor extends WorkerHost { async process(job: Job): Promise { switch (job.name) { + case JOB_ROOM_REMINDER: + await this.studyRoomService.reminderRoom(job.data.groupId) + break + + case JOB_ROOM_END: + await this.studyRoomService.endRoom(job.data.groupId) + break + case JOB_RECONNECT_EXPIRE: await this.studyRoomService.handleReconnectExpiry(job.data) break + default: this.logger.warn( `Unknown job name: ${job.name}`, diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index cc2dfe0462..b774cbf535 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -10,6 +10,8 @@ import { membersKey, SESSION_DURATION_MS, RECONNECT_GRACE_SEC, + REMINDER_BEFORE_END_MS, + BUFFER_SEC, type RoomState, type RoomMember, type JoinResponse, @@ -19,6 +21,8 @@ import { StudyService } from './study.service' export const STUDY_ROOM_QUEUE = 'study-room' export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' +export const JOB_ROOM_REMINDER = 'room-reminder' +export const JOB_ROOM_END = 'room-end' @Injectable() export class StudyRoomService { @@ -90,7 +94,7 @@ export class StudyRoomService { members, startedAt: state.startedAt, endAt: state.endAt, - remainMs: state.endAt - now, + remainMs: Math.max(0, state.endAt - now), hostUserId: state.hostUserId } } @@ -102,8 +106,8 @@ export class StudyRoomService { * Redis의 멤버 목록과 reconnectKey 존재 여부를 조회하여 * 이미 멤버로 등록되어 있으면서 재연결 대기 상태가 아니면 중복 입장으로 판단합니다. * - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID * @returns 입장 가능 여부와 재연결 여부 */ private async checkJoinable( @@ -173,12 +177,12 @@ export class StudyRoomService { * 3. Socket.IO 룸에 참여 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 * - * @param client 연결된 Socket 인스턴스 - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID - * @param membership 사용자 이름과 리더 여부 - * @param now 입장 시각(ms) - * @param isRecovered 재연결 복구 여부 + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 + * @param {number} now 입장 시각(ms) + * @param {boolean} isRecovered 재연결 복구 여부 */ private async enterRoom( client: Socket, @@ -195,6 +199,7 @@ export class StudyRoomService { ) await job?.remove() } + client.data = { ...client.data, userName: membership.userName, @@ -211,6 +216,7 @@ export class StudyRoomService { joinedAt: now }) } + /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * @@ -223,9 +229,7 @@ export class StudyRoomService { state: RoomState, members: RoomMember[] ): Promise { - // 첫 번째: 타이머 시작 + 룸 전체(본인 포함)에 started emit - - // TODO: await this.scheduleJobs() + await this.scheduleJobs(groupId, state.endAt) this.server.to(roomKey(groupId)).emit('room:started', { startedAt: state.startedAt, @@ -267,6 +271,55 @@ export class StudyRoomService { members }) } + + /** + * 세션 시작 시 종료 리마인드 작업과 종료 작업을 BullMQ에 예약합니다. + * + * - room-reminder:{groupId}: 세션 종료 REMINDER_BEFORE_END_MS 전 실행 + * - room-end:{groupId}: 세션 종료 시점에 실행 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} endAt + */ + private async scheduleJobs(groupId: number, endAt: number): Promise { + const now = Date.now() + const delayToEnd = endAt - now + const delayToReminder = delayToEnd - REMINDER_BEFORE_END_MS + + const reminderJobId = `${JOB_ROOM_REMINDER}:${groupId}` + const endJobId = `${JOB_ROOM_END}:${groupId}` + + const [existingReminder, existingEnd] = await Promise.all([ + this.queue.getJob(reminderJobId), + this.queue.getJob(endJobId) + ]) + await Promise.all([existingReminder?.remove(), existingEnd?.remove()]) + + if (delayToReminder > 0) { + await this.queue.add( + JOB_ROOM_REMINDER, + { groupId }, + { + jobId: reminderJobId, + delay: delayToReminder, + removeOnComplete: true, + removeOnFail: true + } + ) + } + + await this.queue.add( + JOB_ROOM_END, + { groupId }, + { + jobId: endJobId, + delay: delayToEnd, + removeOnComplete: true, + removeOnFail: true + } + ) + } + /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * @@ -311,7 +364,7 @@ export class StudyRoomService { reconnectKey(groupId, userId), '1', 'EX', - RECONNECT_GRACE_SEC + 10 + RECONNECT_GRACE_SEC + BUFFER_SEC ) const jobId = `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` @@ -334,6 +387,59 @@ export class StudyRoomService { .emit('room:participantReconnecting', { userId, userName }) } + // BullMQ Processor 호출 + /** + * 세션 종료 전 리마인드 이벤트를 룸 전체에 전송합니다. + * BullMQ Processor에서 예약된 room-reminder 작업이 실행될 때 호출됩니다. + * + * @param {number} groupId 스터디 그룹 ID + */ + async reminderRoom(groupId: number): Promise { + const state = await this.getRoomState(groupId) + if (!state) return + + this.server.to(roomKey(groupId)).emit('room:reminder', { + groupId, + endAt: state.endAt, + remainMs: Math.max(0, state.endAt - Date.now()) + }) + } + + /** + * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. + * + * 1. 룸 전체에 room:ended 이벤트를 전송 + * 2. 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 + * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 + * + * @param {number} groupId 스터디 그룹 ID + */ + async endRoom(groupId: number): Promise { + this.server.to(roomKey(groupId)).emit('room:ended', { groupId }) + + const members = await this.getMembers(groupId) + await Promise.all( + members.map((m) => this.redis.del(reconnectKey(groupId, m.userId))) + ) + + const sockets = await this.server.in(roomKey(groupId)).fetchSockets() + await Promise.all( + sockets.map((s) => { + Object.assign(s.data, { + groupId: undefined, + userId: undefined, + userName: undefined + }) + return s.leave(roomKey(groupId)) + }) + ) + + await Promise.all([ + this.redis.del(roomKey(groupId)), + this.redis.del(membersKey(groupId)) + ]) + } + /** * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. * BullMQ Processor에서 호출됩니다. From fe839ca7d36cdfb143ac1e38f01855c01bb0264a Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 21:13:48 +0000 Subject: [PATCH 20/72] fix(be): resolve race conditions in reconnection and handle expired sessions --- .../client/src/study/study-room.service.ts | 44 +++++++++++-------- .../apps/client/src/study/study.gateway.ts | 4 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index b774cbf535..ef3def1330 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -79,8 +79,8 @@ export class StudyRoomService { if (state && now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } - const isRecovered = check.isRecovered - await this.enterRoom(client, groupId, userId, membership, now, isRecovered!) + const isRecovered = check.isRecovered ?? false + await this.enterRoom(client, groupId, userId, membership, now, isRecovered) const members = await this.getMembers(groupId) @@ -172,7 +172,7 @@ export class StudyRoomService { /** * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. * - * 1. 재연결 입장인 경우 Redis의 재연결 대기 키를 삭제하고 예약되어 있던 재연결 만료 작업 취소 + * 1. 재연결 입장인 경우 reconnectKey 삭제를 시도하고, 예약되어 있던 재연결 만료 작업 취소 * 2. socket.data에 사용자 메타데이터를 저장 * 3. Socket.IO 룸에 참여 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 @@ -193,11 +193,13 @@ export class StudyRoomService { isRecovered: boolean ): Promise { if (isRecovered) { - await this.redis.del(reconnectKey(groupId, userId)) - const job = await this.queue.getJob( - `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` - ) - await job?.remove() + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (deleted) { + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() + } } client.data = { @@ -275,6 +277,7 @@ export class StudyRoomService { /** * 세션 시작 시 종료 리마인드 작업과 종료 작업을 BullMQ에 예약합니다. * + * - delayToEnd <= 0이면 즉시 endRoom 호출 (이미 종료됐어야 하는 룸 처리) * - room-reminder:{groupId}: 세션 종료 REMINDER_BEFORE_END_MS 전 실행 * - room-end:{groupId}: 세션 종료 시점에 실행 * @@ -295,6 +298,11 @@ export class StudyRoomService { ]) await Promise.all([existingReminder?.remove(), existingEnd?.remove()]) + if (delayToEnd <= 0) { + await this.endRoom(groupId) + return + } + if (delayToReminder > 0) { await this.queue.add( JOB_ROOM_REMINDER, @@ -359,6 +367,7 @@ export class StudyRoomService { async handleDisconnect(client: Socket): Promise { const { userId, groupId, userName } = client.data ?? {} if (!userId || !groupId) return + client.data.groupId = undefined await this.redis.set( reconnectKey(groupId, userId), @@ -409,7 +418,7 @@ export class StudyRoomService { * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. * * 1. 룸 전체에 room:ended 이벤트를 전송 - * 2. 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 + * 2. 연결 중인 유저 Key 삭제 후 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 * * @param {number} groupId 스터디 그룹 ID @@ -444,10 +453,11 @@ export class StudyRoomService { * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. * BullMQ Processor에서 호출됩니다. * - * 1. Redis에서 재연결 대기 상태 키 확인 - * 2. 키가 없으면 이미 복구된 것으로 보고 중단 - * 3. 대기 상태 키와 멤버 정보를 삭제 - * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 1. reconnectKey를 원자적으로 삭제 (DEL 반환값으로 복구 여부 판단) + * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 + * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 + * 2. Redis 멤버 목록에서 해당 사용자 삭제 + * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -459,11 +469,9 @@ export class StudyRoomService { }): Promise { const { userId, groupId } = payload - const stillDisconnected = await this.redis.get( - reconnectKey(groupId, userId) - ) - if (!stillDisconnected) return - await this.redis.del(reconnectKey(groupId, userId)) + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (!deleted) return + await this.removeMember(groupId, userId) this.server.to(roomKey(groupId)).emit('room:participantsChanged', { diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 0c524fde33..0e056b2488 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -63,7 +63,7 @@ export class StudyGateway @SubscribeMessage('room:leave') async handleLeave(@ConnectedSocket() client: Socket) { - const userId = client.data.user?.id ?? client.data.userId + const userId = client.data.userId if (!userId) throw new WsException('Unauthorized') const groupId = client.data.groupId if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') @@ -82,7 +82,7 @@ export class StudyGateway client: Socket, rawGroupId: unknown ): { userId: number; groupId: number } { - const userId = client.data.user?.id ?? client.data.userId + const userId = client.data.userId if (!userId) throw new WsException('Unauthorized') const groupId = this.parsePositiveInt(rawGroupId) From 253358fd6054fa0b14c9b1998d558b408872d169 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 21:24:54 +0000 Subject: [PATCH 21/72] chore(be): refine logging and clean up unused code --- .../client/src/study/study-room.service.ts | 10 +++++++- .../apps/client/src/study/study.gateway.ts | 23 +++---------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index ef3def1330..2a5a07a4f6 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -63,8 +63,12 @@ export class StudyRoomService { const userId: number = client.data.userId const check = await this.checkJoinable(groupId, userId) - if (!check.ok) + if (!check.ok) { + this.logger.warn( + `입장 거부: userId=${userId}, groupId=${groupId}, reason=${check.message}` + ) return { success: false, code: check.code, message: check.message } + } const membership = await this.studyService.validateJoinableStudyGroup( groupId, @@ -233,6 +237,10 @@ export class StudyRoomService { ): Promise { await this.scheduleJobs(groupId, state.endAt) + this.logger.log( + `룸 시작: groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` + ) + this.server.to(roomKey(groupId)).emit('room:started', { startedAt: state.startedAt, endAt: state.endAt, diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 0e056b2488..3b384bbd25 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -3,7 +3,6 @@ import { WebSocketGateway, WebSocketServer, OnGatewayInit, - OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, ConnectedSocket, @@ -25,9 +24,7 @@ import { StudyRoomService } from './study-room.service' credentials: true } }) -export class StudyGateway - implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect -{ +export class StudyGateway implements OnGatewayInit, OnGatewayDisconnect { @WebSocketServer() server: Server @@ -37,17 +34,10 @@ export class StudyGateway afterInit(server: Server) { this.studyRoomService.setServer(server) - this.logger.log('✅ StudyGateway initialized') + this.logger.log('StudyGateway initialized') } - // 클라이언트 연결 - handleConnection(client: Socket) { - this.logger.log(`Client connected: socketId=${client.id}`) - } - - // 클라이언트 연결 해제 async handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: socketId=${client.id}`) await this.studyRoomService.handleDisconnect(client) } @@ -57,7 +47,7 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const { userId, groupId } = this.parsePayload(client, payload?.groupId) + const { groupId } = this.parsePayload(client, payload?.groupId) return this.studyRoomService.join(client, groupId) } @@ -71,13 +61,6 @@ export class StudyGateway return this.studyRoomService.leave(client, groupId) } - // ✅ 연결 확인용 ping - // @SubscribeMessage('ping') - // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { - // this.logger.log(`Ping from ${client.id}`) - // return { event: 'pong', data: '✅ Gateway connected!' } - // } - private parsePayload( client: Socket, rawGroupId: unknown From 751a87969e528a5a2f23d68e2197466207ba3f12 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 22:10:39 +0000 Subject: [PATCH 22/72] chore(be): remove unused res field and simplify socket leave logic --- .../apps/client/src/study/study-room.service.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 2a5a07a4f6..3135f7934e 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -67,7 +67,7 @@ export class StudyRoomService { this.logger.warn( `입장 거부: userId=${userId}, groupId=${groupId}, reason=${check.message}` ) - return { success: false, code: check.code, message: check.message } + return { success: false, message: check.message } } const membership = await this.studyService.validateJoinableStudyGroup( @@ -440,16 +440,7 @@ export class StudyRoomService { ) const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all( - sockets.map((s) => { - Object.assign(s.data, { - groupId: undefined, - userId: undefined, - userName: undefined - }) - return s.leave(roomKey(groupId)) - }) - ) + await Promise.all(sockets.map((s) => s.leave(roomKey(groupId)))) await Promise.all([ this.redis.del(roomKey(groupId)), From d056e13109f412ab65eaa4b49e0d3c57a7758c6a Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 26 Apr 2026 16:28:26 +0000 Subject: [PATCH 23/72] feat(be): reassign host on host leave --- .../client/src/study/study-room.service.ts | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3135f7934e..3fad9f0088 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -80,7 +80,7 @@ export class StudyRoomService { if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - if (state && now >= state.endAt) + if (now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } const isRecovered = check.isRecovered ?? false @@ -342,7 +342,8 @@ export class StudyRoomService { * 1. client.data에서 groupId를 초기화 * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 3. Socket.io 룸에서 클라이언트를 제외(leave) - * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 4. 호스트 퇴장 시 새 호스트 지정 + * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID @@ -356,9 +357,11 @@ export class StudyRoomService { await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) - this.server.to(roomKey(groupId)).emit('room:participantsChanged', { - members: await this.getMembers(groupId) - }) + const members = await this.transferHostIfNeeded(groupId, userId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) return { success: true } } @@ -456,7 +459,8 @@ export class StudyRoomService { * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 * 2. Redis 멤버 목록에서 해당 사용자 삭제 - * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 3. 호스트 퇴장 시 새 호스트 지정 + * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -473,9 +477,46 @@ export class StudyRoomService { await this.removeMember(groupId, userId) - this.server.to(roomKey(groupId)).emit('room:participantsChanged', { - members: await this.getMembers(groupId) - }) + const members = await this.transferHostIfNeeded(groupId, userId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) + } + + /** + * 호스트가 퇴장했을 때 새 호스트를 지정합니다. + * 우선순위: isLeader인 멤버 → 없으면 joinedAt 기준 가장 먼저 입장한 멤버 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} leavingUserId 퇴장한 호스트 userId + * @returns 스터디 룸에 남아있는 사용자들 + */ + private async transferHostIfNeeded( + groupId: number, + leavingUserId: number + ): Promise { + const state = await this.getRoomState(groupId) + const members = await this.getMembers(groupId) + + if (state && state.hostUserId === leavingUserId && members.length > 0) { + const newHost = + members.find((m) => m.isLeader) ?? + [...members].sort((a, b) => a.joinedAt - b.joinedAt)[0] + + await this.redis.set( + roomKey(groupId), + JSON.stringify({ ...state, hostUserId: newHost.userId }), + 'KEEPTTL' + ) + + this.server.to(roomKey(groupId)).emit('room:hostChanged', { + hostUserId: newHost.userId, + hostUserName: newHost.userName + }) + } + + return members } // Redis From fe3ee59a6438b04a84ecfdfc90c8532c88d92e8d Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 5 May 2026 16:44:33 +0000 Subject: [PATCH 24/72] refactor(be): use DB endTime for study room expiration --- .../study/interface/study-socket.interface.ts | 5 +--- .../client/src/study/study-room.service.ts | 25 +++++++++++-------- .../apps/client/src/study/study.service.ts | 12 ++++++--- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index d323b61041..3b058e3a19 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -9,15 +9,13 @@ export const reconnectKey = (groupId: number, userId: number) => `${RECONNECT_PREFIX}:${groupId}:${userId}` // 상수 -export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 export const RECONNECT_GRACE_SEC = 30 // 30초 export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 export const BUFFER_SEC = 10 // 10초 // Room export interface RoomState { - startedAt: number // Unix ms - endAt: number // Unix ms + endAt: number hostUserId: number } @@ -39,7 +37,6 @@ export interface SocketResponse { export interface JoinResponse { members: RoomMember[] - startedAt: number endAt: number remainMs: number hostUserId: number diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3fad9f0088..07e58e7a18 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -8,7 +8,6 @@ import { roomKey, reconnectKey, membersKey, - SESSION_DURATION_MS, RECONNECT_GRACE_SEC, REMINDER_BEFORE_END_MS, BUFFER_SEC, @@ -76,12 +75,20 @@ export class StudyRoomService { ) const now = Date.now() - const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) + + if (membership.endTime.getTime() <= now) { + return { success: false, message: '세션이 이미 종료되었습니다.' } + } + + const { state, isFirst } = await this.initOrGetRoom( + groupId, + userId, + now, + membership.endTime + ) if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - if (now >= state.endAt) - return { success: false, message: '세션이 이미 종료되었습니다.' } const isRecovered = check.isRecovered ?? false await this.enterRoom(client, groupId, userId, membership, now, isRecovered) @@ -96,7 +103,6 @@ export class StudyRoomService { success: true, data: { members, - startedAt: state.startedAt, endAt: state.endAt, remainMs: Math.max(0, state.endAt - now), hostUserId: state.hostUserId @@ -153,11 +159,11 @@ export class StudyRoomService { private async initOrGetRoom( groupId: number, userId: number, - now: number + now: number, + endTime: Date ): Promise<{ state: RoomState | null; isFirst: boolean }> { const activeState: RoomState = { - startedAt: now, - endAt: now + SESSION_DURATION_MS, + endAt: endTime.getTime(), hostUserId: userId } @@ -165,7 +171,7 @@ export class StudyRoomService { roomKey(groupId), JSON.stringify(activeState), 'PX', - SESSION_DURATION_MS + 60_000, + endTime.getTime() - now + 60_000, 'NX' )) @@ -242,7 +248,6 @@ export class StudyRoomService { ) this.server.to(roomKey(groupId)).emit('room:started', { - startedAt: state.startedAt, endAt: state.endAt, members }) diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 63107d7926..43096365d5 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -447,7 +447,7 @@ export class StudyService { * * @param {number} groupId 그룹ID * @param {number} userId 유저ID - * @returns 사용자의 ID, 이름, 리더 여부를 포함한 객체 + * @returns 사용자의 ID, 이름, 리더 여부, 만료 시간을 포함한 객체 * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 */ @@ -464,8 +464,13 @@ export class StudyService { userId: true, isGroupLeader: true, user: { + select: { username: true } + }, + group: { select: { - username: true + studyInfo: { + select: { endTime: true } + } } } } @@ -478,7 +483,8 @@ export class StudyService { return { userId: membership.userId, userName: membership.user.username, - isLeader: membership.isGroupLeader + isLeader: membership.isGroupLeader, + endTime: membership.group.studyInfo!.endTime } } } From 906486d09ab5c2c231bb593c4705947bc2c1dbbd Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 5 May 2026 18:21:25 +0000 Subject: [PATCH 25/72] refactor(be): standardize key prefix structure for study --- .../src/study/interface/study-socket.interface.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 3b058e3a19..041a94ff46 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,12 +1,15 @@ // Redis Key -export const ROOM_PREFIX = 'study-room' -export const RECONNECT_PREFIX = 'study-reconnect' +export const STUDY_KEY_PREFIX = 'study' +export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` +export const STUDY_CHAT_PREFIX = `${STUDY_KEY_PREFIX}:chat` -export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` +export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` export const membersKey = (groupId: number) => - `${ROOM_PREFIX}:${groupId}:members` + `${STUDY_ROOM_PREFIX}:${groupId}:members` export const reconnectKey = (groupId: number, userId: number) => - `${RECONNECT_PREFIX}:${groupId}:${userId}` + `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` + +export const chatKey = (groupId: number) => `${STUDY_CHAT_PREFIX}:${groupId}` // 상수 export const RECONNECT_GRACE_SEC = 30 // 30초 From e11aafb3efae09368f4a53b65cbdba072430a31d Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 12 May 2026 15:51:19 +0000 Subject: [PATCH 26/72] refactor(be): add safe Redis member parsing with error handling --- .../apps/client/src/study/study-room.service.ts | 15 +++++++++++---- apps/backend/libs/redis/src/redis.module.ts | 1 - 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 07e58e7a18..d12991f240 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -38,6 +38,7 @@ export class StudyRoomService { this.server = server } + // Room /** * 사용자 WebSocket을 스터디 룸에 입장시킵니다. * @@ -351,11 +352,10 @@ export class StudyRoomService { * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 - * @param {number} groupId 스터디 그룹 ID * @returns 퇴장 성공 여부 */ - async leave(client: Socket, groupId: number): Promise { - const { userId } = client.data + async leave(client: Socket): Promise { + const { userId, groupId } = client.data client.data.groupId = undefined @@ -574,6 +574,13 @@ export class StudyRoomService { async getMembers(groupId: number): Promise { const raw = await this.redis.hgetall(membersKey(groupId)) if (!raw) return [] - return Object.values(raw).map((v) => JSON.parse(v) as RoomMember) + return Object.values(raw).flatMap((v) => { + try { + return [JSON.parse(v) as RoomMember] + } catch { + this.logger.error(`멤버 데이터 파싱 실패`) + return [] + } + }) } } diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts index 57c19a9b86..0fc8d3ce11 100644 --- a/apps/backend/libs/redis/src/redis.module.ts +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -30,7 +30,6 @@ export const REDIS_CLIENT = 'REDIS_CLIENT' throw new Error(`Redis connection failed: ${error.message}`) } - // console.log('✅ Redis connected') return redis }, inject: [ConfigService] From 065989462cc46a8ac94f038cd9a76c5da74d7458 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:41:43 +0000 Subject: [PATCH 27/72] refactor(be): normalize socket data structure for study room --- .../client/src/study/study-room.service.ts | 29 +++++++++++------ .../apps/client/src/study/study.gateway.ts | 31 +++++++------------ .../libs/auth/src/jwt/jwt-auth.guard.ts | 3 -- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index d12991f240..c22cdd7c2a 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -60,7 +60,7 @@ export class StudyRoomService { client: Socket, groupId: number ): Promise> { - const userId: number = client.data.userId + const userId: number = client.data.user.id const check = await this.checkJoinable(groupId, userId) if (!check.ok) { @@ -213,8 +213,7 @@ export class StudyRoomService { } } - client.data = { - ...client.data, + client.data.room = { userName: membership.userName, isLeader: membership.isLeader, groupId @@ -282,8 +281,8 @@ export class StudyRoomService { members: RoomMember[] ): void { this.server.to(roomKey(groupId)).emit('room:participantReconnected', { - userId: client.data.userId, - userName: client.data.userName, + userId: client.data.user.id, + userName: client.data.room.userName, members }) } @@ -355,9 +354,10 @@ export class StudyRoomService { * @returns 퇴장 성공 여부 */ async leave(client: Socket): Promise { - const { userId, groupId } = client.data + const userId = client.data.user.id + const groupId = client.data.room.groupId - client.data.groupId = undefined + client.data.room = undefined await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) @@ -381,9 +381,13 @@ export class StudyRoomService { * @param {Socket} client 연결된 Socket 인스턴스 */ async handleDisconnect(client: Socket): Promise { - const { userId, groupId, userName } = client.data ?? {} + const userId = client.data.user?.id + + const groupId = client.data.room?.groupId if (!userId || !groupId) return - client.data.groupId = undefined + + const userName = client.data.room.userName + client.data.room = undefined await this.redis.set( reconnectKey(groupId, userId), @@ -448,7 +452,12 @@ export class StudyRoomService { ) const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all(sockets.map((s) => s.leave(roomKey(groupId)))) + await Promise.all( + sockets.map((s) => { + s.data.room = undefined + s.leave(roomKey(groupId)) + }) + ) await Promise.all([ this.redis.del(roomKey(groupId)), diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 3b384bbd25..3b47d76d81 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -47,36 +47,27 @@ export class StudyGateway implements OnGatewayInit, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const { groupId } = this.parsePayload(client, payload?.groupId) + const groupId = this.parseGroupId(payload?.groupId) return this.studyRoomService.join(client, groupId) } @SubscribeMessage('room:leave') async handleLeave(@ConnectedSocket() client: Socket) { - const userId = client.data.userId - if (!userId) throw new WsException('Unauthorized') - const groupId = client.data.groupId - if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') - - return this.studyRoomService.leave(client, groupId) + this.assertInRoom(client) + return this.studyRoomService.leave(client) } - private parsePayload( - client: Socket, - rawGroupId: unknown - ): { userId: number; groupId: number } { - const userId = client.data.userId - if (!userId) throw new WsException('Unauthorized') + private parseGroupId(rawGroupId: unknown): number { + const groupId = Number(rawGroupId) - const groupId = this.parsePositiveInt(rawGroupId) - if (!groupId) throw new WsException('groupId must be a positive integer') + if (!Number.isInteger(groupId) || groupId <= 0) + throw new WsException('groupId must be a positive integer') - return { userId, groupId } + return groupId } - private parsePositiveInt(value: unknown): number | null { - const num = Number(value) - if (!Number.isInteger(num) || num <= 0) return null - return num + private assertInRoom(client: Socket): void { + if (!client.data.room?.groupId) + throw new WsException('룸에 참여 중이 아닙니다.') } } diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index d41bebfa5b..cef98384f0 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -69,9 +69,6 @@ export class JwtAuthGuard extends AuthGuard('jwt') { throw err || new UnauthorizedException() } client.data.user = user - client.data.userId = user.id ?? user.userId - client.data.userName = user.username - client.data.userRole = user.role ?? user.userRole return user } From 0f5071ce1fa4b852e1d90ea5bebe6aea13e64b66 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:45:22 +0000 Subject: [PATCH 28/72] refactor(be): improve study room join validation and lifecycle --- .../client/src/study/study-room.service.ts | 27 +++++++++++++------ .../apps/client/src/study/study.service.ts | 7 ++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index c22cdd7c2a..ab6a826213 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -70,10 +70,15 @@ export class StudyRoomService { return { success: false, message: check.message } } - const membership = await this.studyService.validateJoinableStudyGroup( - groupId, - userId - ) + let membership + try { + membership = await this.studyService.validateJoinableStudyGroup( + groupId, + userId + ) + } catch { + return { success: false, message: '입장 권한이 없습니다.' } + } const now = Date.now() @@ -94,6 +99,12 @@ export class StudyRoomService { const isRecovered = check.isRecovered ?? false await this.enterRoom(client, groupId, userId, membership, now, isRecovered) + if (isFirst) + await this.redis.expireat( + membersKey(groupId), + Math.ceil((membership.endTime.getTime() + 60_000) / 1000) + ) + const members = await this.getMembers(groupId) if (isFirst) await this.onFirstJoin(groupId, state, members) @@ -105,7 +116,7 @@ export class StudyRoomService { data: { members, endAt: state.endAt, - remainMs: Math.max(0, state.endAt - now), + remainMs: Math.max(0, state.endAt - Date.now()), hostUserId: state.hostUserId } } @@ -130,14 +141,14 @@ export class StudyRoomService { code?: string message?: string }> { - const [existingMember, reconnectRaw] = await Promise.all([ - this.redis.hget(membersKey(groupId), String(userId)), + const [memberExists, reconnectRaw] = await Promise.all([ + this.redis.hexists(membersKey(groupId), String(userId)), this.redis.get(reconnectKey(groupId, userId)) ]) const isRecovered = !!reconnectRaw - if (existingMember && !isRecovered) + if (memberExists && !isRecovered) return { ok: false, message: '이미 이 룸에 참여 중입니다.' } return { ok: true, isRecovered } diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 43096365d5..e26af381d6 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -450,6 +450,7 @@ export class StudyService { * @returns 사용자의 ID, 이름, 리더 여부, 만료 시간을 포함한 객체 * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 + * - 해당 스터디 그룹의 endTime이 없을 경우 발생 */ async validateJoinableStudyGroup(groupId: number, userId: number) { const membership = await this.prisma.userGroup.findUnique({ @@ -480,11 +481,15 @@ export class StudyService { throw new ForbiddenAccessException('User ID is not in the group') } + if (!membership.group.studyInfo?.endTime) { + throw new ForbiddenAccessException('Study group session is not available') + } + return { userId: membership.userId, userName: membership.user.username, isLeader: membership.isGroupLeader, - endTime: membership.group.studyInfo!.endTime + endTime: membership.group.studyInfo.endTime } } } From 05332bf3369d3b3886aab3fbe0ad58cd3952f875 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:45:47 +0000 Subject: [PATCH 29/72] refactor(be): remove unused study room member fields --- .../apps/client/src/study/interface/study-socket.interface.ts | 1 - apps/backend/apps/client/src/study/study-room.service.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 041a94ff46..08388efc59 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -24,7 +24,6 @@ export interface RoomState { export interface RoomMember { userId: number - socketId: string userName: string isLeader: boolean joinedAt: number diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index ab6a826213..a7096c1b1b 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -229,11 +229,11 @@ export class StudyRoomService { isLeader: membership.isLeader, groupId } + client.join(roomKey(groupId)) await this.addMember(groupId, userId, { userId, - socketId: client.id, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now @@ -413,7 +413,7 @@ export class StudyRoomService { await this.queue.add( JOB_RECONNECT_EXPIRE, - { userId, groupId, userName }, + { userId, groupId }, { jobId, delay: RECONNECT_GRACE_SEC * 1000, From 45bbfa25ebaf27ef8fcfc1b7a9df6ed09227bb75 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:48:46 +0000 Subject: [PATCH 30/72] docs(be): update JSDoc for study room leave --- apps/backend/apps/client/src/study/study-room.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index a7096c1b1b..6c976639c4 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -355,7 +355,7 @@ export class StudyRoomService { /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * - * 1. client.data에서 groupId를 초기화 + * 1. client.data.room을 초기화하여 룸 데이터 제거 * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 3. Socket.io 룸에서 클라이언트를 제외(leave) * 4. 호스트 퇴장 시 새 호스트 지정 From d895f9da657b64bf9b3a77880690edc0884ec5d0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 16:12:39 +0000 Subject: [PATCH 31/72] fix(be): add Redis password to BullMQ --- apps/backend/apps/client/src/app.module.ts | 7 ++++--- apps/backend/libs/redis/src/redis.module.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index 2cf13452e0..259ff8b3e4 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -61,10 +61,11 @@ import { WorkbookModule } from './workbook/workbook.module' RedisModule, BullModule.forRootAsync({ inject: [ConfigService], - useFactory: (config: ConfigService) => ({ + useFactory: (configService: ConfigService) => ({ connection: { - host: config.get('REDIS_HOST'), - port: config.get('REDIS_PORT'), + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), db: 1 }, prefix: 'bull-client' diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts index 0fc8d3ce11..b7e3a5fe23 100644 --- a/apps/backend/libs/redis/src/redis.module.ts +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -10,16 +10,17 @@ export const REDIS_CLIENT = 'REDIS_CLIENT' providers: [ { provide: REDIS_CLIENT, - useFactory: async (config: ConfigService) => { - const host = config.get('REDIS_HOST') - const port = config.get('REDIS_PORT') + useFactory: async (configService: ConfigService) => { + const host = configService.get('REDIS_HOST') + const port = configService.get('REDIS_PORT') + const password = configService.get('REDIS_PASSWORD') const db = 1 if (!host || !port) { throw new Error('Redis host and port must be configured') } - const redis = new Redis({ host, port, db }) + const redis = new Redis({ host, port, password, db }) try { const result = await redis.ping() From 1c57e0d37c72fa429a9130d7159215a46cdea414 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 14 May 2026 08:46:36 +0000 Subject: [PATCH 32/72] refactor(be): prevent race condition in enterRoom --- .../client/src/study/study-room.service.ts | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 6c976639c4..3296692b56 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -46,6 +46,8 @@ export class StudyRoomService { * 2. 사용자의 스터디 그룹 참여 권한 검증 * 3. Redis에서 룸 상태 생성 또는 기존 상태 조회하고 유효성 검증 * 4. Socket.IO 룸 입장 및 Redis 멤버 정보 등록 + * - 신규: HSETNX로 중복 입장 차단 + * - 재연결: reconnectKey DEL 성공 여부로 중복 재연결 차단 * 5. 입장 케이스에 따라 이벤트 emit * - 첫 번째 입장이면 room:started 이벤트 브로드캐스트 * - 재연결 입장이면 room:participantReconnected 이벤트 emit @@ -97,7 +99,16 @@ export class StudyRoomService { return { success: false, message: '룸 상태 가져오지 못했습니다.' } const isRecovered = check.isRecovered ?? false - await this.enterRoom(client, groupId, userId, membership, now, isRecovered) + const entered = await this.enterRoom( + client, + groupId, + userId, + membership, + now, + isRecovered + ) + if (!entered) + return { success: false, message: '이미 이 룸에 참여 중입니다.' } if (isFirst) await this.redis.expireat( @@ -194,9 +205,13 @@ export class StudyRoomService { /** * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. * - * 1. 재연결 입장인 경우 reconnectKey 삭제를 시도하고, 예약되어 있던 재연결 만료 작업 취소 + * 1. 재연결 입장인 경우 reconnectKey를 원자적으로 삭제 + * - 삭제 실패(0)이면 다른 요청이 이미 재연결 처리 중으로 보고 false 반환 + * - 삭제 성공(1)이면 예약된 재연결 만료 작업 취소 * 2. socket.data에 사용자 메타데이터를 저장 - * 3. Socket.IO 룸에 참여 + * 3. 신규 입장인 경우 HSETNX로 원자적으로 멤버 등록 + * - 이미 존재하면(0) 중복 입장으로 판단 → false 반환 + * - 재연결 입장인 경우 HSET으로 멤버 정보 갱신 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 * * @param {Socket} client 연결된 Socket 인스턴스 @@ -205,6 +220,7 @@ export class StudyRoomService { * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 * @param {number} now 입장 시각(ms) * @param {boolean} isRecovered 재연결 복구 여부 + * @returns 입장 성공 여부 (false면 중복 입장 또는 재연결 경쟁 패배) */ private async enterRoom( client: Socket, @@ -213,15 +229,15 @@ export class StudyRoomService { membership: { userName: string; isLeader: boolean }, now: number, isRecovered: boolean - ): Promise { + ): Promise { if (isRecovered) { const deleted = await this.redis.del(reconnectKey(groupId, userId)) - if (deleted) { - const job = await this.queue.getJob( - `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` - ) - await job?.remove() - } + if (!deleted) return false + + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() } client.data.room = { @@ -230,14 +246,30 @@ export class StudyRoomService { groupId } - client.join(roomKey(groupId)) - - await this.addMember(groupId, userId, { + const member: RoomMember = { userId, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now - }) + } + + if (isRecovered) { + await this.redis.hset( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) + } else { + const added = await this.redis.hsetnx( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) + if (!added) return false + } + + client.join(roomKey(groupId)) + return true } /** @@ -556,25 +588,6 @@ export class StudyRoomService { return raw ? (JSON.parse(raw) as RoomState) : null } - /** - * Redis Hash에 멤버 정보를 사용자 ID를 키로 하여 추가합니다. - * - * @param {number} groupId 스터디 그룹 ID - * @param {number} userId 사용자 ID - * @param {RoomMember} member 저장할 멤버 정보 - */ - private async addMember( - groupId: number, - userId: number, - member: RoomMember - ): Promise { - await this.redis.hset( - membersKey(groupId), - String(userId), - JSON.stringify(member) - ) - } - /** * Redis Hash에서 특정 사용자의 멤버 정보를 삭제합니다. * From 5c374ecb0998177ae0ac5b87fe54a0c02723ac70 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 14 May 2026 12:31:13 +0000 Subject: [PATCH 33/72] refactor(be): improve room cleanup and edge case handling in StudyRoomService --- .../client/src/study/study-room.service.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3296692b56..11b02b161b 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -388,10 +388,11 @@ export class StudyRoomService { * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * * 1. client.data.room을 초기화하여 룸 데이터 제거 - * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 - * 3. Socket.io 룸에서 클라이언트를 제외(leave) - * 4. 호스트 퇴장 시 새 호스트 지정 - * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 2. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 3. Redis Hash에서 해당 사용자의 멤버 정보 삭제 + * 4. Socket.io 룸에서 클라이언트를 제외(leave) + * 5. 호스트 퇴장 시 새 호스트 지정 + * 6. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @returns 퇴장 성공 여부 @@ -402,6 +403,9 @@ export class StudyRoomService { client.data.room = undefined + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return { success: true } + await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) @@ -417,9 +421,10 @@ export class StudyRoomService { /** * 웹소켓 연결이 끊어졌을 때 호출되며, 재연결 유예 기간(Grace Period)을 부여합니다. * - * 1. Redis에 재연결 대기 상태 키 저장 - * 2. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 - * 3. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 + * 1. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 2. Redis에 재연결 대기 상태 키 저장 + * 3. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 + * 4. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 * * @param {Socket} client 연결된 Socket 인스턴스 */ @@ -429,6 +434,9 @@ export class StudyRoomService { const groupId = client.data.room?.groupId if (!userId || !groupId) return + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return + const userName = client.data.room.userName client.data.room = undefined @@ -481,8 +489,10 @@ export class StudyRoomService { * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. * * 1. 룸 전체에 room:ended 이벤트를 전송 - * 2. 연결 중인 유저 Key 삭제 후 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 - * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 + * 2. 재연결 대기 중인 모든 멤버의 reconnectKey를 Redis에서 삭제 + * 3. Redis의 룸 상태(roomKey)와 멤버 목록(membersKey)을 삭제 + * 4. 룸에 남아 있는 모든 소켓의 WebSocket 연결을 끊음 (disconnectSockets) + * — 멀티 노드 환경에서도 각 노드가 자신의 소켓을 직접 disconnect 처리함 * * @param {number} groupId 스터디 그룹 ID */ @@ -494,18 +504,12 @@ export class StudyRoomService { members.map((m) => this.redis.del(reconnectKey(groupId, m.userId))) ) - const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all( - sockets.map((s) => { - s.data.room = undefined - s.leave(roomKey(groupId)) - }) - ) - await Promise.all([ this.redis.del(roomKey(groupId)), this.redis.del(membersKey(groupId)) ]) + + await this.server.in(roomKey(groupId)).disconnectSockets() } /** @@ -606,7 +610,6 @@ export class StudyRoomService { */ async getMembers(groupId: number): Promise { const raw = await this.redis.hgetall(membersKey(groupId)) - if (!raw) return [] return Object.values(raw).flatMap((v) => { try { return [JSON.parse(v) as RoomMember] From da3a676c995f2547f21248464cc92dc51b1aeded Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 24 May 2026 12:31:55 +0000 Subject: [PATCH 34/72] feat(be): restrict WebSocket CORS origin by encironment --- apps/backend/apps/client/src/study/study.gateway.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 3b47d76d81..2d67dad190 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -18,9 +18,13 @@ import { StudyRoomService } from './study-room.service' @WebSocketGateway({ namespace: 'study', cors: { - // TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 - // origin: ['ws://localhost:3002/api/room'] - origin: true, + origin: + process.env.APP_ENV === 'production' || process.env.APP_ENV === 'stage' + ? true + : [ + /^https:\/\/\d+\.preview\.codedang\.com$/, // preview + /^http:\/\/localhost(:\d+)?$/ // local + ], credentials: true } }) From 261d8fc351c2a7df6cfc99a41aec2de726967374 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 24 May 2026 13:30:30 +0000 Subject: [PATCH 35/72] refactor(be): move study-room constants to libs --- .../study/interface/study-socket.interface.ts | 18 --------------- .../client/src/study/study-room.processor.ts | 4 ++-- .../client/src/study/study-room.service.ts | 23 ++++++++++--------- apps/backend/libs/constants/src/index.ts | 1 + .../constants/src/study-room.constants.ts | 20 ++++++++++++++++ 5 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 apps/backend/libs/constants/src/study-room.constants.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 08388efc59..872b739e53 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,21 +1,3 @@ -// Redis Key -export const STUDY_KEY_PREFIX = 'study' -export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` -export const STUDY_CHAT_PREFIX = `${STUDY_KEY_PREFIX}:chat` - -export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` -export const membersKey = (groupId: number) => - `${STUDY_ROOM_PREFIX}:${groupId}:members` -export const reconnectKey = (groupId: number, userId: number) => - `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` - -export const chatKey = (groupId: number) => `${STUDY_CHAT_PREFIX}:${groupId}` - -// 상수 -export const RECONNECT_GRACE_SEC = 30 // 30초 -export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 -export const BUFFER_SEC = 10 // 10초 - // Room export interface RoomState { endAt: number diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts index 56dddcfaab..baf14ee61b 100644 --- a/apps/backend/apps/client/src/study/study-room.processor.ts +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -2,12 +2,12 @@ import { Processor, WorkerHost } from '@nestjs/bullmq' import { Logger } from '@nestjs/common' import { Job } from 'bullmq' import { - StudyRoomService, STUDY_ROOM_QUEUE, JOB_RECONNECT_EXPIRE, JOB_ROOM_REMINDER, JOB_ROOM_END -} from './study-room.service' +} from '@libs/constants' +import { StudyRoomService } from './study-room.service' @Processor(STUDY_ROOM_QUEUE) export class StudyRoomProcessor extends WorkerHost { diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 11b02b161b..d49f1830ee 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -3,26 +3,27 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import type { Queue } from 'bullmq' import type Redis from 'ioredis' import type { Server, Socket } from 'socket.io' -import { REDIS_CLIENT } from '@libs/redis' import { roomKey, reconnectKey, membersKey, + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END, RECONNECT_GRACE_SEC, REMINDER_BEFORE_END_MS, - BUFFER_SEC, - type RoomState, - type RoomMember, - type JoinResponse, - type SocketResponse + BUFFER_SEC +} from '@libs/constants' +import { REDIS_CLIENT } from '@libs/redis' +import type { + RoomState, + RoomMember, + JoinResponse, + SocketResponse } from './interface/study-socket.interface' import { StudyService } from './study.service' -export const STUDY_ROOM_QUEUE = 'study-room' -export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' -export const JOB_ROOM_REMINDER = 'room-reminder' -export const JOB_ROOM_END = 'room-end' - @Injectable() export class StudyRoomService { private readonly logger = new Logger(StudyRoomService.name) diff --git a/apps/backend/libs/constants/src/index.ts b/apps/backend/libs/constants/src/index.ts index 825dbb8be4..a2c24164ce 100644 --- a/apps/backend/libs/constants/src/index.ts +++ b/apps/backend/libs/constants/src/index.ts @@ -5,3 +5,4 @@ export * from './check.constants' export * from './submission.constants' export * from './argon2.constants' export * from './score-calculation.constants' +export * from './study-room.constants' diff --git a/apps/backend/libs/constants/src/study-room.constants.ts b/apps/backend/libs/constants/src/study-room.constants.ts new file mode 100644 index 0000000000..6d4b605cb9 --- /dev/null +++ b/apps/backend/libs/constants/src/study-room.constants.ts @@ -0,0 +1,20 @@ +// Redis Key +export const STUDY_KEY_PREFIX = 'study' +export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` + +export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` +export const membersKey = (groupId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:members` +export const reconnectKey = (groupId: number, userId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` + +// BullMQ Queue Job Names +export const STUDY_ROOM_QUEUE = 'study-room' +export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' +export const JOB_ROOM_REMINDER = 'room-reminder' +export const JOB_ROOM_END = 'room-end' + +// 상수 +export const RECONNECT_GRACE_SEC = 30 // 30초 +export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 +export const BUFFER_SEC = 10 // 10초 From 4cc3df359cef88f77796f7669b30f9a5558f7700 Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 26 May 2026 12:20:10 +0000 Subject: [PATCH 36/72] refactor(be): remove host and simplify room state --- .../study/interface/study-socket.interface.ts | 5 +- .../client/src/study/study-room.service.ts | 86 ++++++++----------- 2 files changed, 36 insertions(+), 55 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 872b739e53..0d8b7c631e 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,13 +1,12 @@ // Room export interface RoomState { endAt: number - hostUserId: number + leaderId: number | null } export interface RoomMember { userId: number userName: string - isLeader: boolean joinedAt: number } @@ -23,7 +22,7 @@ export interface JoinResponse { members: RoomMember[] endAt: number remainMs: number - hostUserId: number + leaderId: number | null } // PayLoad diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index d49f1830ee..df1afb41a7 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -92,6 +92,7 @@ export class StudyRoomService { const { state, isFirst } = await this.initOrGetRoom( groupId, userId, + membership.isLeader, now, membership.endTime ) @@ -104,7 +105,7 @@ export class StudyRoomService { client, groupId, userId, - membership, + membership.userName, now, isRecovered ) @@ -129,7 +130,7 @@ export class StudyRoomService { members, endAt: state.endAt, remainMs: Math.max(0, state.endAt - Date.now()), - hostUserId: state.hostUserId + leaderId: state.leaderId } } } @@ -174,6 +175,7 @@ export class StudyRoomService { * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 * - 성공이면, 첫 번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 + * - 리더 입장이고 leaderId가 미설정 상태이면 KEEPTTL로 업데이트 * * @param {number} groupId 스터디 그룹 ID * @param {number} userId 현재 사용자 ID @@ -183,12 +185,13 @@ export class StudyRoomService { private async initOrGetRoom( groupId: number, userId: number, + isLeader: boolean, now: number, endTime: Date ): Promise<{ state: RoomState | null; isFirst: boolean }> { const activeState: RoomState = { endAt: endTime.getTime(), - hostUserId: userId + leaderId: isLeader ? userId : null } const isFirst = !!(await this.redis.set( @@ -199,7 +202,16 @@ export class StudyRoomService { 'NX' )) - const state = isFirst ? activeState : await this.getRoomState(groupId) + if (isFirst) return { state: activeState, isFirst } + + const state = await this.getRoomState(groupId) + + if (isLeader && state && state.leaderId === null) { + const updated = { ...state, leaderId: userId } + await this.redis.set(roomKey(groupId), JSON.stringify(updated), 'KEEPTTL') + return { state: updated, isFirst } + } + return { state, isFirst } } @@ -218,7 +230,7 @@ export class StudyRoomService { * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @param {number} userId 사용자 ID - * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 + * @param {string} userName 사용자 이름과 리더 여부 * @param {number} now 입장 시각(ms) * @param {boolean} isRecovered 재연결 복구 여부 * @returns 입장 성공 여부 (false면 중복 입장 또는 재연결 경쟁 패배) @@ -227,7 +239,7 @@ export class StudyRoomService { client: Socket, groupId: number, userId: number, - membership: { userName: string; isLeader: boolean }, + userName: string, now: number, isRecovered: boolean ): Promise { @@ -242,23 +254,30 @@ export class StudyRoomService { } client.data.room = { - userName: membership.userName, - isLeader: membership.isLeader, + userName, groupId } const member: RoomMember = { userId, - userName: membership.userName, - isLeader: membership.isLeader, + userName, joinedAt: now } if (isRecovered) { + const existing = await this.redis.hget( + membersKey(groupId), + String(userId) + ) + + if (!existing) return false + + const { joinedAt: originalJoinedAt } = JSON.parse(existing) as RoomMember + await this.redis.hset( membersKey(groupId), String(userId), - JSON.stringify(member) + JSON.stringify({ ...member, joinedAt: originalJoinedAt }) ) } else { const added = await this.redis.hsetnx( @@ -392,8 +411,7 @@ export class StudyRoomService { * 2. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) * 3. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 4. Socket.io 룸에서 클라이언트를 제외(leave) - * 5. 호스트 퇴장 시 새 호스트 지정 - * 6. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @returns 퇴장 성공 여부 @@ -410,7 +428,7 @@ export class StudyRoomService { await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) - const members = await this.transferHostIfNeeded(groupId, userId) + const members = await this.getMembers(groupId) this.server .to(roomKey(groupId)) @@ -521,8 +539,7 @@ export class StudyRoomService { * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 * 2. Redis 멤버 목록에서 해당 사용자 삭제 - * 3. 호스트 퇴장 시 새 호스트 지정 - * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -539,48 +556,13 @@ export class StudyRoomService { await this.removeMember(groupId, userId) - const members = await this.transferHostIfNeeded(groupId, userId) + const members = await this.getMembers(groupId) this.server .to(roomKey(groupId)) .emit('room:participantsChanged', { members }) } - /** - * 호스트가 퇴장했을 때 새 호스트를 지정합니다. - * 우선순위: isLeader인 멤버 → 없으면 joinedAt 기준 가장 먼저 입장한 멤버 - * - * @param {number} groupId 스터디 그룹 ID - * @param {number} leavingUserId 퇴장한 호스트 userId - * @returns 스터디 룸에 남아있는 사용자들 - */ - private async transferHostIfNeeded( - groupId: number, - leavingUserId: number - ): Promise { - const state = await this.getRoomState(groupId) - const members = await this.getMembers(groupId) - - if (state && state.hostUserId === leavingUserId && members.length > 0) { - const newHost = - members.find((m) => m.isLeader) ?? - [...members].sort((a, b) => a.joinedAt - b.joinedAt)[0] - - await this.redis.set( - roomKey(groupId), - JSON.stringify({ ...state, hostUserId: newHost.userId }), - 'KEEPTTL' - ) - - this.server.to(roomKey(groupId)).emit('room:hostChanged', { - hostUserId: newHost.userId, - hostUserName: newHost.userName - }) - } - - return members - } - // Redis /** * Redis에서 룸 상태를 조회합니다. From 90394e447daac01609dab0a5c6f0ea4338710e96 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 05:54:00 +0000 Subject: [PATCH 37/72] feat(be): install websokets, redisAdapter, ioredis for studygroup --- apps/backend/package.json | 6 + pnpm-lock.yaml | 307 +++++++++++++++++++++++++++++++++++--- 2 files changed, 290 insertions(+), 23 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 255ef4cc88..84e3a00f61 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,7 +39,9 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", + "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/swagger": "^11.2.0", + "@nestjs/websockets": "^11.1.17", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", @@ -59,6 +61,7 @@ "@opentelemetry/semantic-conventions": "^1.36.0", "@prisma/client": "^6.13.0", "@prisma/instrumentation": "~6.13.0", + "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.1", "argon2": "^0.44.0", "axios": "^1.11.0", @@ -80,6 +83,7 @@ "graphql-upload": "^17.0.0", "handlebars": "^4.7.8", "he": "^1.2.0", + "ioredis": "^5.10.1", "keyv": "^5.5.0", "nestjs-otel": "^6.2.0", "nestjs-pino": "^4.4.0", @@ -92,9 +96,11 @@ "pino-http": "^10.5.0", "pino-pretty": "^11.3.0", "prisma-graphql-type-decimal": "^3.0.1", + "redis": "^5.11.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.2", "sanitize-filename": "^1.6.3", + "socket.io": "^4.8.3", "sql-formatter": "^15.6.6", "unzipper": "^0.12.3", "web-push": "^3.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 088af18b4f..4ff4f2d575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 4.0.2(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.6 - version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^3.0.1 version: 3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) @@ -154,9 +154,15 @@ importers: '@nestjs/platform-express': specifier: ^11.1.6 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/platform-socket.io': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) '@nestjs/swagger': specifier: ^11.2.0 version: 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/websockets': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -214,6 +220,9 @@ importers: '@prisma/instrumentation': specifier: ~6.13.0 version: 6.13.0(@opentelemetry/api@1.9.0) + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.6) archiver: specifier: ^7.0.1 version: 7.0.1 @@ -277,6 +286,9 @@ importers: he: specifier: ^1.2.0 version: 1.2.0 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 keyv: specifier: ^5.5.0 version: 5.5.0 @@ -313,6 +325,9 @@ importers: prisma-graphql-type-decimal: specifier: ^3.0.1 version: 3.0.1(@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2))(graphql@16.11.0) + redis: + specifier: ^5.11.0 + version: 5.11.0 reflect-metadata: specifier: ^0.2.1 version: 0.2.2 @@ -322,6 +337,9 @@ importers: sanitize-filename: specifier: ^1.6.3 version: 1.6.3 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 sql-formatter: specifier: ^15.6.6 version: 15.6.6 @@ -3047,8 +3065,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} @@ -3506,6 +3524,13 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io@11.1.17': + resolution: {integrity: sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/schematics@11.0.7': resolution: {integrity: sha512-t8dNYYMwEeEsrlwc2jbkfwCfXczq4AeNEgx1KVQuJ6wYibXk0ZbXbPdfp8scnEAaQv1grpncNV5gWgzi7ZwbvQ==} peerDependencies: @@ -3541,6 +3566,18 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/websockets@11.1.17': + resolution: {integrity: sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@next/bundle-analyzer@15.5.18': resolution: {integrity: sha512-v5/UNFwYbBlRQg/Bt+wU65XuxCxPu1AeCOI6s4s6Cludsj7FdVO9E9uzr7GIj8OykSrYtGuEQAUX0Ulje8W2yw==} @@ -4677,10 +4714,43 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@redis/client@5.8.0': resolution: {integrity: sha512-ywZjKGoSSAECGYOd9bJpws6d4867SN686obUWT/sRmo1c/Q8V+jWyInvlqwKa0BOvTHHwYeB2WFUEvd6PADeOQ==} engines: {node: '>= 18'} + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -5121,6 +5191,15 @@ packages: resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@socket.io/redis-adapter@8.3.0': + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -5778,6 +5857,9 @@ packages: '@types/cookies@0.9.1': resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -6748,6 +6830,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} @@ -7476,6 +7562,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -7757,6 +7852,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -8862,8 +8965,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.7.0: - resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} ipaddr.js@1.9.1: @@ -10263,6 +10366,9 @@ packages: resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} engines: {node: '>=14.16'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -10308,6 +10414,10 @@ packages: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -11225,6 +11335,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -11647,6 +11761,17 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -12332,6 +12457,10 @@ packages: uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -15454,14 +15583,14 @@ snapshots: '@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.21 '@golevelup/nestjs-rabbitmq@5.7.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@golevelup/nestjs-discovery': 4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) amqp-connection-manager: 4.1.14(amqplib@0.10.8) amqplib: 0.10.8 lodash: 4.17.21 @@ -16255,7 +16384,7 @@ snapshots: optionalDependencies: '@types/node': 22.17.1 - '@ioredis/commands@1.3.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/balanced-match@4.0.1': {} @@ -16576,7 +16705,7 @@ snapshots: dependencies: '@css-inline/css-inline': 0.14.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) glob: 10.3.12 nodemailer: 6.10.1 optionalDependencies: @@ -16597,7 +16726,7 @@ snapshots: '@apollo/server': 4.12.2(graphql@16.11.0) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@4.12.2(graphql@16.11.0)) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/graphql': 13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2) graphql: 16.11.0 iterall: 1.3.0 @@ -16613,21 +16742,21 @@ snapshots: '@nestjs/bull-shared@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 '@nestjs/bullmq@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(bullmq@5.58.0)': dependencies: '@nestjs/bull-shared': 11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.58.0 tslib: 2.8.1 '@nestjs/cache-manager@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(cache-manager@7.1.1)(keyv@5.5.0)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cache-manager: 7.1.1 keyv: 5.5.0 rxjs: 7.8.2 @@ -16685,7 +16814,7 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 @@ -16698,11 +16827,12 @@ snapshots: uid: 2.0.2 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 '@nestjs/graphql@13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2)': @@ -16711,7 +16841,7 @@ snapshots: '@graphql-tools/schema': 10.0.23(graphql@16.11.0) '@graphql-tools/utils': 10.8.6(graphql@16.11.0) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) chokidar: 4.0.3 fast-glob: 3.3.3 @@ -16756,7 +16886,7 @@ snapshots: '@nestjs/platform-express@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -16765,6 +16895,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-socket.io@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + socket.io: 4.8.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@nestjs/schematics@11.0.7(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) @@ -16791,7 +16933,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 @@ -16805,11 +16947,24 @@ snapshots: '@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-express@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + + '@nestjs/websockets@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-socket.io': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) + '@next/bundle-analyzer@15.5.18': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -17978,10 +18133,30 @@ snapshots: dependencies: react: 19.1.1 + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + '@redis/client@5.8.0': dependencies: cluster-key-slot: 1.1.2 + '@redis/json@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + '@remirror/core-constants@3.0.0': {} '@repeaterjs/repeater@3.0.6': {} @@ -18503,6 +18678,17 @@ snapshots: '@smithy/types': 4.3.2 tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.6 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@standard-schema/spec@1.0.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -19197,6 +19383,10 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 22.17.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.17.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -20346,6 +20536,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + base64url@3.0.1: {} big-integer@1.6.52: {} @@ -20474,7 +20666,7 @@ snapshots: bullmq@5.58.0: dependencies: cron-parser: 4.9.0 - ioredis: 5.7.0 + ioredis: 5.10.1 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.2 @@ -21183,6 +21375,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -21435,6 +21631,25 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.17.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -22835,9 +23050,9 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.7.0: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.3.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.1(supports-color@8.1.1) denque: 2.1.0 @@ -24299,7 +24514,7 @@ snapshots: nestjs-otel@6.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6): dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.35.5(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -24434,6 +24649,8 @@ snapshots: normalize-url@8.0.2: {} + notepack.io@3.0.1: {} + npm-run-path@2.0.2: dependencies: path-key: 2.0.1 @@ -24503,6 +24720,8 @@ snapshots: object-hash@2.2.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -25574,6 +25793,16 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -26091,6 +26320,36 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -26794,6 +27053,8 @@ snapshots: uid2@0.0.4: {} + uid2@1.0.0: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0 From 4c4fbc2b9145c578921cfb966ae3e1394b9c220f Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:22:23 +0000 Subject: [PATCH 38/72] feat(be): add Redis module and Socket.IO adapter --- apps/backend/libs/redis/src/index.ts | 2 + .../libs/redis/src/redis-io.adapter.ts | 30 ++++++++++++++ apps/backend/libs/redis/src/redis.module.ts | 41 +++++++++++++++++++ apps/backend/libs/redis/tsconfig.lib.json | 9 ++++ 4 files changed, 82 insertions(+) create mode 100644 apps/backend/libs/redis/src/index.ts create mode 100644 apps/backend/libs/redis/src/redis-io.adapter.ts create mode 100644 apps/backend/libs/redis/src/redis.module.ts create mode 100644 apps/backend/libs/redis/tsconfig.lib.json diff --git a/apps/backend/libs/redis/src/index.ts b/apps/backend/libs/redis/src/index.ts new file mode 100644 index 0000000000..3ae8bfff33 --- /dev/null +++ b/apps/backend/libs/redis/src/index.ts @@ -0,0 +1,2 @@ +export * from './redis.module' +export * from './redis-io.adapter' diff --git a/apps/backend/libs/redis/src/redis-io.adapter.ts b/apps/backend/libs/redis/src/redis-io.adapter.ts new file mode 100644 index 0000000000..a5893af92d --- /dev/null +++ b/apps/backend/libs/redis/src/redis-io.adapter.ts @@ -0,0 +1,30 @@ +import type { INestApplicationContext } from '@nestjs/common' +import { IoAdapter } from '@nestjs/platform-socket.io' +import { createAdapter } from '@socket.io/redis-adapter' +import type Redis from 'ioredis' +import type { ServerOptions } from 'socket.io' + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType + + constructor( + app: INestApplicationContext, + private readonly redisClient: Redis + ) { + super(app) + } + + async connectToRedis() { + const pubClient = this.redisClient + const subClient = pubClient.duplicate() + this.adapterConstructor = createAdapter(pubClient, subClient) + } + + createIOServer(port: number, options?: ServerOptions) { + const server = super.createIOServer(port, options) + if (this.adapterConstructor) { + server.adapter(this.adapterConstructor) + } + return server + } +} diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts new file mode 100644 index 0000000000..57c19a9b86 --- /dev/null +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -0,0 +1,41 @@ +// apps/backend/libs/redis/src/redis.module.ts +import { Global, Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import Redis from 'ioredis' + +export const REDIS_CLIENT = 'REDIS_CLIENT' + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: async (config: ConfigService) => { + const host = config.get('REDIS_HOST') + const port = config.get('REDIS_PORT') + const db = 1 + + if (!host || !port) { + throw new Error('Redis host and port must be configured') + } + + const redis = new Redis({ host, port, db }) + + try { + const result = await redis.ping() + if (result !== 'PONG') { + throw new Error('Unexpected ping response') + } + } catch (error) { + throw new Error(`Redis connection failed: ${error.message}`) + } + + // console.log('✅ Redis connected') + return redis + }, + inject: [ConfigService] + } + ], + exports: [REDIS_CLIENT] +}) +export class RedisModule {} diff --git a/apps/backend/libs/redis/tsconfig.lib.json b/apps/backend/libs/redis/tsconfig.lib.json new file mode 100644 index 0000000000..1f4c603050 --- /dev/null +++ b/apps/backend/libs/redis/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/redis" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} From 1ed587d81e85cf3c9c0eb553b93744f6377856d1 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:24:16 +0000 Subject: [PATCH 39/72] feat(be): add WebSocket auth to JWT auth guard --- .../libs/auth/src/jwt/jwt-auth.guard.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index ab8cb253f7..b84c851af9 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -6,6 +6,7 @@ import { import { Reflector } from '@nestjs/core' import { type GqlContextType, GqlExecutionContext } from '@nestjs/graphql' import { AuthGuard } from '@nestjs/passport' +import { Socket } from 'socket.io' import { AUTH_NOT_NEEDED_KEY, USER_NULL_WHEN_AUTH_FAILED @@ -18,12 +19,19 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } canActivate(context: ExecutionContext) { - const request = this.getRequest(context) const isAuthNotNeeded = this.reflector.getAllAndOverride( AUTH_NOT_NEEDED_KEY, [context.getHandler(), context.getClass()] ) - if (isAuthNotNeeded && !request.query.groupId) { + const request = this.getRequest(context) + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + } + + if (isAuthNotNeeded && !groupId) { return true } return super.canActivate(context) @@ -33,12 +41,30 @@ export class JwtAuthGuard extends AuthGuard('jwt') { if (context.getType() === 'graphql') { return GqlExecutionContext.create(context).getContext().req } + + // ws 컨텍스트 — handshake에서 JWT 토큰 추출 + if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() + const handshake = client.handshake + + if (handshake.auth?.token) { + handshake.headers.authorization = `Bearer ${handshake.auth.token}` + } + + return handshake + } return super.getRequest(context) } handleRequest(err, user, info, context: ExecutionContext) { const request = this.getRequest(context) - const groupId = request.query.groupId + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + } + const userNullWhenAuthFailed = this.reflector.getAllAndOverride( USER_NULL_WHEN_AUTH_FAILED, [context.getHandler(), context.getClass()] From c1f24ea69772d4d03ba4982a216375fdfa53a060 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:30:18 +0000 Subject: [PATCH 40/72] feat(be): integrate Redis module into application --- apps/backend/apps/client/src/app.module.ts | 4 +++- apps/backend/tsconfig.json | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index e7fe311671..55c4b3bccd 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -12,6 +12,7 @@ import { ClientExceptionFilter } from '@libs/exception' import { openTelemetryModuleOption } from '@libs/instrumentation' import { pinoLoggerModuleOption } from '@libs/logger' import { PrismaModule } from '@libs/prisma' +import { RedisModule } from '@libs/redis' import { AnnouncementModule } from './announcement/announcement.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -55,7 +56,8 @@ import { WorkbookModule } from './workbook/workbook.module' NotificationModule, LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot(openTelemetryModuleOption), - StudyModule + StudyModule, + RedisModule ], controllers: [AppController], providers: [ diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9d66c721ea..22d537cdb7 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -31,7 +31,8 @@ "@libs/types": ["./libs/types/src/index.ts"], "@libs/instrumentation": ["./libs/instrumentation.ts"], "@libs/storage": ["./libs/storage/src/index.ts"], - "@libs/amqp": ["./libs/amqp/src/index.ts"] + "@libs/amqp": ["./libs/amqp/src/index.ts"], + "@libs/redis": ["./libs/redis/src/index.ts"] } } } From 701c79b734b90e9b956103ca72da33e9e44c2b19 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 06:57:34 +0000 Subject: [PATCH 41/72] feat(be): add Redis WebSocket adapter to main --- apps/backend/apps/client/src/main.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/main.ts b/apps/backend/apps/client/src/main.ts index 5655d3ec21..92ff5eeb5d 100644 --- a/apps/backend/apps/client/src/main.ts +++ b/apps/backend/apps/client/src/main.ts @@ -2,11 +2,13 @@ import { ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import cookieParser from 'cookie-parser' +import type Redis from 'ioredis' import Instrumentation from '@libs/instrumentation' +import { REDIS_CLIENT, RedisIoAdapter } from '@libs/redis' /** - TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 - 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 + TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 + 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 자세한 이유는 [이 comment](https://github.com/skkuding/codedang/pull/2705#discussion_r2072945663)를 참고해주세요. */ const bootstrap = async () => { @@ -29,6 +31,13 @@ const bootstrap = async () => { app.useGlobalInterceptors(new LoggerErrorInterceptor()) app.useGlobalPipes(new ValidationPipe({ whitelist: true })) app.use(cookieParser()) + + // Redis WebSocket adapter 연결 + const redisClient = app.get(REDIS_CLIENT) + const redisIoAdapter = new RedisIoAdapter(app, redisClient) + await redisIoAdapter.connectToRedis() // redis 연결 + app.useWebSocketAdapter(redisIoAdapter) // adapter 등록 + if (process.env.APP_ENV !== 'production' && process.env.APP_ENV !== 'stage') { app.enableCors({ origin: 'http://localhost:5525', From d491a994b3710b535a35f8bd64effa2d98adc48d Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 07:08:23 +0000 Subject: [PATCH 42/72] feat(be): add WebSocket gateway for study group --- .../apps/client/src/study/study.gateway.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/backend/apps/client/src/study/study.gateway.ts diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts new file mode 100644 index 0000000000..10369fdcd3 --- /dev/null +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -0,0 +1,42 @@ +import { Logger } from '@nestjs/common' +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect +} from '@nestjs/websockets' +import type { Server, Socket } from 'socket.io' + +// TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 +@WebSocketGateway({ + namespace: 'study', + cors: { origin: true, credentials: true } +}) +export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server + + private readonly logger = new Logger(StudyGateway.name) + + // ✅ Gateway 초기화 완료 확인용 + // afterInit() { + // this.logger.log('✅ StudyGateway initialized') + // } + + // 클라이언트 연결 + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`) + } + + // 클라이언트 연결 해제 + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`) + } + + // ✅ 연결 확인용 ping + // @SubscribeMessage('ping') + // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { + // this.logger.log(`Ping from ${client.id}`) + // return { event: 'pong', data: '✅ Gateway connected!' } + // } +} From a01d583e1770981c5009ef64bea2bd72b673fc01 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 07:09:06 +0000 Subject: [PATCH 43/72] feat(be): add StudyGateway to StudyModule --- apps/backend/apps/client/src/study/study.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index a926826c84..1272ce873b 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' import { StudyController } from './study.controller' +import { StudyGateway } from './study.gateway' import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService], + providers: [StudyService, StudyGateway], imports: [RolesModule] }) export class StudyModule {} From cdcf088a9f0757e7ae1aeed43a97d0479239df66 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 2 Apr 2026 14:56:52 +0000 Subject: [PATCH 44/72] feat(be): add validateJoinableStudyGroup in study service --- .../apps/client/src/study/study.service.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 823ff038b5..63107d7926 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -441,4 +441,44 @@ export class StudyService { } }) } + + /** + * 사용자가 해당 스터디 그룹 소속인지 검증하고, 회원 정보 반환합니다. + * + * @param {number} groupId 그룹ID + * @param {number} userId 유저ID + * @returns 사용자의 ID, 이름, 리더 여부를 포함한 객체 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 + */ + async validateJoinableStudyGroup(groupId: number, userId: number) { + const membership = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { + userId, + groupId + } + }, + select: { + userId: true, + isGroupLeader: true, + user: { + select: { + username: true + } + } + } + }) + + if (!membership) { + throw new ForbiddenAccessException('User ID is not in the group') + } + + return { + userId: membership.userId, + userName: membership.user.username, + isLeader: membership.isGroupLeader + } + } } From f753892fa617bceac3872c694a5380ab03055daf Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 17:51:47 +0000 Subject: [PATCH 45/72] feat(be): add StudyRoom handleJoin() --- .../study/interface/study-socket.interface.ts | 46 ++++ .../client/src/study/study-room.service.ts | 215 ++++++++++++++++++ .../apps/client/src/study/study.gateway.ts | 60 ++++- .../apps/client/src/study/study.module.ts | 3 +- 4 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 apps/backend/apps/client/src/study/interface/study-socket.interface.ts create mode 100644 apps/backend/apps/client/src/study/study-room.service.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts new file mode 100644 index 0000000000..92a9a99c4b --- /dev/null +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -0,0 +1,46 @@ +// Redis Key +export const ROOM_PREFIX = 'study-room' +export const RECONNECT_PREFIX = 'study-reconnect' + +export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` +export const membersKey = (groupId: number) => + `${ROOM_PREFIX}:${groupId}:members` + +// 상수 +export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 + +// Room +export interface RoomState { + startedAt: number // Unix ms + endAt: number // Unix ms + hostUserId: number +} + +// Member +export interface RoomMember { + userId: number + userName: string + isLeader: boolean + joinedAt: number +} + +// Socket Response +export interface SocketResponse { + success: boolean + message?: string + data?: T + code?: string +} + +export interface JoinResponse { + members: RoomMember[] + startedAt: number + endAt: number + remainMs: number + hostUserId: number +} + +// PayLoad +export interface JoinPayload { + groupId: number +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts new file mode 100644 index 0000000000..460aa4a11a --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -0,0 +1,215 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type Redis from 'ioredis' +import type { Server, Socket } from 'socket.io' +import { REDIS_CLIENT } from '@libs/redis' +import { + roomKey, + membersKey, + SESSION_DURATION_MS, + type RoomState, + type RoomMember, + type JoinResponse, + type SocketResponse +} from './interface/study-socket.interface' +import { StudyService } from './study.service' + +@Injectable() +export class StudyRoomService { + private readonly logger = new Logger(StudyRoomService.name) + private server: Server + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly studyService: StudyService + ) {} + + setServer(server: Server): void { + this.server = server + } + + /** + * 사용자 WebSocket을 스터디 룸에 입장시킨다. + * + * 1. 사용자의 그룹 참여 권한 검증 + * 2. 재연결 복구 확인 + * 3. socket.data에 사용자 메타데이터 저장하고 room에 join() + * 4. Redis에서 룸 상태 생성 또는 조회 + * 5. 세션 종료 여부 검증 + * 6. Redis에 멤버 등록하고 전체 멤버 조회 + * 7. 첫번째 입장이면 room:started 이벤트 브로드케스트 + * 그외 입장이면 room:participantsChanged 이벤트 emit + * 8. 클라이언트에 현재 룸 상태 반환 + * + * @param client 연결된 Socket 인스턴스 + * @param groupId 스터디 그룹 ID + * @returns 현재 룸 상태 + */ + async join( + client: Socket, + groupId: number + ): Promise> { + const userId: number = client.data.userId + const membership = await this.studyService.validateJoinableStudyGroup( + groupId, + userId + ) + + // TODO: reconnect 확인 + + client.data = { + userId, + userName: membership.userName, + isLeader: membership.isLeader, + groupId + } + client.join(roomKey(groupId)) + + const now = Date.now() + const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) + + if (!state || now >= state.endAt) { + client.leave(roomKey(groupId)) + return { success: false, message: '세션이 이미 종료되었습니다.' } + } + + await this.addMember(groupId, client.id, { + userId, + userName: membership.userName, + isLeader: membership.isLeader, + joinedAt: now + }) + + const members = await this.getMembers(groupId) + + if (isFirst) this.onFirstJoin(groupId, state, members) + else this.onSubsequentJoin(client, groupId, members) + + return { + success: true, + data: { + members, + startedAt: state.startedAt, + endAt: state.endAt, + remainMs: state.endAt - now, + hostUserId: state.hostUserId + } + } + } + + /** + * 스터디 룸을 생성하거나 기존 상태를 조회한다. + * + * 1. 현재 시간을 기준으로 RoomState 상태를 생성 + * 2. Redis에 SET NX로 roomKey 저장 시도 + * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 + * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 + * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 + * + * @param groupId 스터디 그룹 ID + * @param userId 현재 사용자 ID + * @param now 현재 시간 + * @returns 룸 상태값, 사용자의 입장이 첫번째인지 + */ + private async initOrGetRoom( + groupId: number, + userId: number, + now: number + ): Promise<{ state: RoomState | null; isFirst: boolean }> { + const activeState: RoomState = { + startedAt: now, + endAt: now + SESSION_DURATION_MS, + hostUserId: userId + } + + const isFirst = !!(await this.redis.set( + roomKey(groupId), + JSON.stringify(activeState), + 'NX' + )) + + // isFirst가 ok이면 첫번째 / null이면 이미 룸 존재 + const state = isFirst ? activeState : await this.getRoomState(groupId) + return { state, isFirst } + } + + /** + * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. + * + * @param groupId 스터디 그룹 ID + * @param state 룸 상태 + * @param members 현재 참여자 목록 + */ + private async onFirstJoin( + groupId: number, + state: RoomState, + members: RoomMember[] + ): Promise { + // 첫 번째: 타이머 시작 + 룸 전체(본인 포함)에 started emit + + // TODO: await this.scheduleJobs() + + this.server.to(roomKey(groupId)).emit('room:started', { + startedAt: state.startedAt, + endAt: state.endAt, + members + }) + + this.logger.log( + `room start🚀 : groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` + ) + } + + /** + * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. + * + * @param client 현재 소켓 + * @param groupId 스터디 그룹 ID + * @param members 최신 참여자 목록 + */ + private onSubsequentJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) + } + + // Redis + /** + * Redis에서 룸 상태를 조회합니다. + * + * @param groupId 스터디 그룹 ID + * @returns 룸 상태값 | null + */ + private async getRoomState(groupId: number): Promise { + const raw = await this.redis.get(roomKey(groupId)) + return raw ? (JSON.parse(raw) as RoomState) : null + } + + /** + * Redis Hash에 소켓 기준으로 멤버를 추가합니다. + * + * @param groupId 스터디 그룹 ID + * @param socketId Socket ID + * @param member 저장할 멤버 정보 + */ + private async addMember( + groupId: number, + socketId: string, + member: RoomMember + ): Promise { + await this.redis.hset(membersKey(groupId), socketId, JSON.stringify(member)) + } + + /** + * Redis Hash에 저장된 모든 멤버를 조회합니다. + * + * @param groupId 스터디 그룹 ID + * @returns 스터디 룸에 있는 모든 멤버들 + */ + async getMembers(groupId: number): Promise { + const raw = await this.redis.hgetall(membersKey(groupId)) + if (!raw) return [] + return Object.values(raw).map((v) => JSON.parse(v) as RoomMember) + } +} diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 10369fdcd3..c4541175ac 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -1,31 +1,48 @@ -import { Logger } from '@nestjs/common' +import { Logger, UseGuards } from '@nestjs/common' import { WebSocketGateway, WebSocketServer, + OnGatewayInit, OnGatewayConnection, - OnGatewayDisconnect + OnGatewayDisconnect, + SubscribeMessage, + ConnectedSocket, + MessageBody, + WsException } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' +import { JwtAuthGuard } from '@libs/auth' +import type { JoinPayload } from './interface/study-socket.interface' +import { StudyRoomService } from './study-room.service' -// TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 +@UseGuards(JwtAuthGuard) @WebSocketGateway({ namespace: 'study', - cors: { origin: true, credentials: true } + cors: { + // TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 + // origin: ['ws://localhost:3002/api/room'] + origin: true, + credentials: true + } }) -export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { +export class StudyGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ @WebSocketServer() server: Server private readonly logger = new Logger(StudyGateway.name) - // ✅ Gateway 초기화 완료 확인용 - // afterInit() { - // this.logger.log('✅ StudyGateway initialized') - // } + constructor(private readonly studyRoomService: StudyRoomService) {} + + afterInit(server: Server) { + this.studyRoomService.setServer(server) + this.logger.log('✅ StudyGateway initialized') + } // 클라이언트 연결 handleConnection(client: Socket) { - this.logger.log(`Client connected: ${client.id}`) + this.logger.log(`🔥 Client connected: ${client.id}`) } // 클라이언트 연결 해제 @@ -33,10 +50,33 @@ export class StudyGateway implements OnGatewayConnection, OnGatewayDisconnect { this.logger.log(`Client disconnected: ${client.id}`) } + // Room + @SubscribeMessage('room:join') + async handleJoin( + @ConnectedSocket() client: Socket, + @MessageBody() payload: JoinPayload + ) { + console.log('🔥 join 들어옴') + + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(payload?.groupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + + return this.studyRoomService.join(client, groupId) + } + // ✅ 연결 확인용 ping // @SubscribeMessage('ping') // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { // this.logger.log(`Ping from ${client.id}`) // return { event: 'pong', data: '✅ Gateway connected!' } // } + + private parsePositiveInt(value: unknown): number | null { + const num = Number(value) + if (!Number.isInteger(num) || num <= 0) return null + return num + } } diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index 1272ce873b..57651dc950 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { StudyRoomService } from './study-room.service' import { StudyController } from './study.controller' import { StudyGateway } from './study.gateway' import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService, StudyGateway], + providers: [StudyService, StudyGateway, StudyRoomService], imports: [RolesModule] }) export class StudyModule {} From b916700c30a6db4e89ddb46bd28fdb9c4af75447 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 17:53:12 +0000 Subject: [PATCH 46/72] fix(be): add user data handling logic to websocket handler --- apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index b84c851af9..d41bebfa5b 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -61,8 +61,19 @@ export class JwtAuthGuard extends AuthGuard('jwt') { let groupId = request.query?.groupId if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() const data = context.switchToWs().getData() groupId = data?.groupId ?? groupId + + if (err || !user) { + throw err || new UnauthorizedException() + } + client.data.user = user + client.data.userId = user.id ?? user.userId + client.data.userName = user.username + client.data.userRole = user.role ?? user.userRole + + return user } const userNullWhenAuthFailed = this.reflector.getAllAndOverride( From bd7226e4413aa7294c42262aeabd0dab3452244f Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:06:14 +0000 Subject: [PATCH 47/72] fix(be): map user id instead of socket id --- .../apps/client/src/study/study-room.service.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 460aa4a11a..aee009eb44 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -72,7 +72,7 @@ export class StudyRoomService { return { success: false, message: '세션이 이미 종료되었습니다.' } } - await this.addMember(groupId, client.id, { + await this.addMember(groupId, userId, { userId, userName: membership.userName, isLeader: membership.isLeader, @@ -190,15 +190,19 @@ export class StudyRoomService { * Redis Hash에 소켓 기준으로 멤버를 추가합니다. * * @param groupId 스터디 그룹 ID - * @param socketId Socket ID + * @param userId 사용자 ID * @param member 저장할 멤버 정보 */ private async addMember( groupId: number, - socketId: string, + userId: number, member: RoomMember ): Promise { - await this.redis.hset(membersKey(groupId), socketId, JSON.stringify(member)) + await this.redis.hset( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) } /** From c62f77f5bef3f3aa31436cc952adcc88157711a0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:09:42 +0000 Subject: [PATCH 48/72] fix(be): fix spread to preserve data --- apps/backend/apps/client/src/study/study-room.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index aee009eb44..19222db1a9 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -57,7 +57,7 @@ export class StudyRoomService { // TODO: reconnect 확인 client.data = { - userId, + ...client.data, userName: membership.userName, isLeader: membership.isLeader, groupId From 4fe37edbca7595ad7c42f094665cf713f8fd919a Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 18:20:53 +0000 Subject: [PATCH 49/72] fix(be): add params type --- .../client/src/study/study-room.service.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 19222db1a9..992f9ef0f1 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -40,8 +40,8 @@ export class StudyRoomService { * 그외 입장이면 room:participantsChanged 이벤트 emit * 8. 클라이언트에 현재 룸 상태 반환 * - * @param client 연결된 Socket 인스턴스 - * @param groupId 스터디 그룹 ID + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID * @returns 현재 룸 상태 */ async join( @@ -67,7 +67,11 @@ export class StudyRoomService { const now = Date.now() const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) - if (!state || now >= state.endAt) { + if (!state) { + client.leave(roomKey(groupId)) + return { success: false, message: '룸 상태 가져오지 못했습니다.' } + } + if (now >= state.endAt) { client.leave(roomKey(groupId)) return { success: false, message: '세션이 이미 종료되었습니다.' } } @@ -105,9 +109,9 @@ export class StudyRoomService { * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 * - * @param groupId 스터디 그룹 ID - * @param userId 현재 사용자 ID - * @param now 현재 시간 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 현재 사용자 ID + * @param {number} now 현재 시간 * @returns 룸 상태값, 사용자의 입장이 첫번째인지 */ private async initOrGetRoom( @@ -135,9 +139,9 @@ export class StudyRoomService { /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * - * @param groupId 스터디 그룹 ID - * @param state 룸 상태 - * @param members 현재 참여자 목록 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomState} state 룸 상태 + * @param {RoomMember[]} members 현재 참여자 목록 */ private async onFirstJoin( groupId: number, @@ -162,9 +166,9 @@ export class StudyRoomService { /** * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. * - * @param client 현재 소켓 - * @param groupId 스터디 그룹 ID - * @param members 최신 참여자 목록 + * @param {Socket} client 현재 소켓 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 */ private onSubsequentJoin( client: Socket, @@ -178,7 +182,7 @@ export class StudyRoomService { /** * Redis에서 룸 상태를 조회합니다. * - * @param groupId 스터디 그룹 ID + * @param {number} groupId 스터디 그룹 ID * @returns 룸 상태값 | null */ private async getRoomState(groupId: number): Promise { @@ -189,9 +193,9 @@ export class StudyRoomService { /** * Redis Hash에 소켓 기준으로 멤버를 추가합니다. * - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID - * @param member 저장할 멤버 정보 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @param {RoomMember} member 저장할 멤버 정보 */ private async addMember( groupId: number, @@ -208,7 +212,7 @@ export class StudyRoomService { /** * Redis Hash에 저장된 모든 멤버를 조회합니다. * - * @param groupId 스터디 그룹 ID + * @param {number} groupId 스터디 그룹 ID * @returns 스터디 룸에 있는 모든 멤버들 */ async getMembers(groupId: number): Promise { From 1bec0a702324a17e9db395bd7527cc3a578618c8 Mon Sep 17 00:00:00 2001 From: westsunh Date: Fri, 24 Apr 2026 19:04:15 +0000 Subject: [PATCH 50/72] feat(be): add StudyRoom handleLeave() --- .../study/interface/study-socket.interface.ts | 5 ++ .../client/src/study/study-room.service.ts | 46 +++++++++++++++++-- .../apps/client/src/study/study.gateway.ts | 27 +++++++++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 92a9a99c4b..df496d17a3 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -19,6 +19,7 @@ export interface RoomState { // Member export interface RoomMember { userId: number + socketId: string userName: string isLeader: boolean joinedAt: number @@ -44,3 +45,7 @@ export interface JoinResponse { export interface JoinPayload { groupId: number } + +export interface LeavePayload { + groupId: number +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 992f9ef0f1..64ab835e0c 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -78,6 +78,7 @@ export class StudyRoomService { await this.addMember(groupId, userId, { userId, + socketId: client.id, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now @@ -85,7 +86,7 @@ export class StudyRoomService { const members = await this.getMembers(groupId) - if (isFirst) this.onFirstJoin(groupId, state, members) + if (isFirst) await this.onFirstJoin(groupId, state, members) else this.onSubsequentJoin(client, groupId, members) return { @@ -131,7 +132,6 @@ export class StudyRoomService { 'NX' )) - // isFirst가 ok이면 첫번째 / null이면 이미 룸 존재 const state = isFirst ? activeState : await this.getRoomState(groupId) return { state, isFirst } } @@ -178,6 +178,34 @@ export class StudyRoomService { client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) } + /** + * 사용자가 스터디 룸을 정상적으로 퇴장합니다. + * + * 1. client.data에서 groupId를 초기화 + * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 + * 3. Socket.io 룸에서 클라이언트를 제외(leave) + * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * + * @param {Socket} client 현재 소켓 + * @param {number} groupId 스터디 그룹 ID + * @returns 퇴장 성공 여부 + */ + async leave(client: Socket, groupId: number): Promise { + const { userId } = client.data + + client.data.groupId = undefined + + await this.removeMember(groupId, userId) + client.leave(roomKey(groupId)) + + this.server.to(roomKey(groupId)).emit('room:participantsChanged', { + members: await this.getMembers(groupId) + }) + + this.logger.log(`leave 👋 : userId=${userId}, groupId=${groupId}`) + return { success: true } + } + // Redis /** * Redis에서 룸 상태를 조회합니다. @@ -191,7 +219,7 @@ export class StudyRoomService { } /** - * Redis Hash에 소켓 기준으로 멤버를 추가합니다. + * Redis Hash에 멤버 정보를 사용자 ID를 키로 하여 추가합니다. * * @param {number} groupId 스터디 그룹 ID * @param {number} userId 사용자 ID @@ -210,7 +238,17 @@ export class StudyRoomService { } /** - * Redis Hash에 저장된 모든 멤버를 조회합니다. + * Redis Hash에서 특정 사용자의 멤버 정보를 삭제합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + */ + private async removeMember(groupId: number, userId: number): Promise { + await this.redis.hdel(membersKey(groupId), String(userId)) + } + + /** + * Redis Hash에 저장된 해당 스터디 룸의 모든 멤버를 조회합니다. * * @param {number} groupId 스터디 그룹 ID * @returns 스터디 룸에 있는 모든 멤버들 diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index c4541175ac..38dacbe708 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -12,7 +12,10 @@ import { } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' import { JwtAuthGuard } from '@libs/auth' -import type { JoinPayload } from './interface/study-socket.interface' +import type { + JoinPayload, + LeavePayload +} from './interface/study-socket.interface' import { StudyRoomService } from './study-room.service' @UseGuards(JwtAuthGuard) @@ -42,12 +45,12 @@ export class StudyGateway // 클라이언트 연결 handleConnection(client: Socket) { - this.logger.log(`🔥 Client connected: ${client.id}`) + this.logger.log(`Client connected: socketId=${client.id}`) } // 클라이언트 연결 해제 handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: ${client.id}`) + this.logger.log(`Client disconnected: socketId=${client.id}`) } // Room @@ -56,17 +59,31 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - console.log('🔥 join 들어옴') - const userId = client.data.user?.id ?? client.data.userId if (!userId) throw new WsException('Unauthorized') + console.log(`🔥 room:join 시작: userId=${userId}`) + const groupId = this.parsePositiveInt(payload?.groupId) if (!groupId) throw new WsException('groupId must be a positive integer') return this.studyRoomService.join(client, groupId) } + @SubscribeMessage('room:leave') + async handleLeave( + @ConnectedSocket() client: Socket, + @MessageBody() payload: LeavePayload + ) { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(payload?.groupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + console.log(`🔥 room:leave 시작: userId=${client.data.userId}`) + return this.studyRoomService.leave(client, groupId) + } + // ✅ 연결 확인용 ping // @SubscribeMessage('ping') // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { From 4307c1dfb2507306d25f1b6026620ea1517cc9e7 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 04:13:49 +0000 Subject: [PATCH 51/72] refactor(be): separate parsePayload logic --- .../apps/client/src/study/study.gateway.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 38dacbe708..14a25874eb 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -59,14 +59,8 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const userId = client.data.user?.id ?? client.data.userId - if (!userId) throw new WsException('Unauthorized') - - console.log(`🔥 room:join 시작: userId=${userId}`) - - const groupId = this.parsePositiveInt(payload?.groupId) - if (!groupId) throw new WsException('groupId must be a positive integer') - + const { userId, groupId } = this.parsePayload(client, payload?.groupId) + console.log(`🏠room:join 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.join(client, groupId) } @@ -75,12 +69,8 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: LeavePayload ) { - const userId = client.data.user?.id ?? client.data.userId - if (!userId) throw new WsException('Unauthorized') - - const groupId = this.parsePositiveInt(payload?.groupId) - if (!groupId) throw new WsException('groupId must be a positive integer') - console.log(`🔥 room:leave 시작: userId=${client.data.userId}`) + const { userId, groupId } = this.parsePayload(client, payload?.groupId) + console.log(`🏠room:leave 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.leave(client, groupId) } @@ -91,6 +81,19 @@ export class StudyGateway // return { event: 'pong', data: '✅ Gateway connected!' } // } + private parsePayload( + client: Socket, + rawGroupId: unknown + ): { userId: number; groupId: number } { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + + const groupId = this.parsePositiveInt(rawGroupId) + if (!groupId) throw new WsException('groupId must be a positive integer') + + return { userId, groupId } + } + private parsePositiveInt(value: unknown): number | null { const num = Number(value) if (!Number.isInteger(num) || num <= 0) return null From e87f11132b54a4f4724b92f0083774c1ed192da3 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 05:41:05 +0000 Subject: [PATCH 52/72] feat(be): setup BullMQ for study-room --- apps/backend/apps/client/src/app.module.ts | 16 ++++++++++++++-- .../apps/client/src/study/study.module.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index 55c4b3bccd..2cf13452e0 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -1,7 +1,8 @@ import { MailerModule } from '@nestjs-modules/mailer' +import { BullModule } from '@nestjs/bullmq' import { CacheModule } from '@nestjs/cache-manager' import { Module, type OnApplicationBootstrap } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_FILTER, APP_GUARD, HttpAdapterHost } from '@nestjs/core' import type { Server } from 'http' import { OpenTelemetryModule } from 'nestjs-otel' @@ -57,7 +58,18 @@ import { WorkbookModule } from './workbook/workbook.module' LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot(openTelemetryModuleOption), StudyModule, - RedisModule + RedisModule, + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + connection: { + host: config.get('REDIS_HOST'), + port: config.get('REDIS_PORT'), + db: 1 + }, + prefix: 'bull-client' + }) + }) ], controllers: [AppController], providers: [ diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index 57651dc950..6a56bd44c4 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,5 +1,8 @@ +import { BullModule } from '@nestjs/bullmq' import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { StudyRoomProcessor } from './study-room.processor' +import { STUDY_ROOM_QUEUE } from './study-room.service' import { StudyRoomService } from './study-room.service' import { StudyController } from './study.controller' import { StudyGateway } from './study.gateway' @@ -7,7 +10,10 @@ import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService, StudyGateway, StudyRoomService], - imports: [RolesModule] + providers: [StudyService, StudyGateway, StudyRoomService, StudyRoomProcessor], + imports: [ + RolesModule, + BullModule.registerQueue({ name: STUDY_ROOM_QUEUE }) // 'study-room + ] }) export class StudyModule {} From d9c8b3c9625cc938dd3a2a4b6f672147ffc35188 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 17:20:40 +0000 Subject: [PATCH 53/72] feat(be): implement socket disconnect and reconnect recovery logic --- .../study/interface/study-socket.interface.ts | 7 +- .../client/src/study/study-room.processor.ts | 30 +++ .../client/src/study/study-room.service.ts | 247 ++++++++++++++---- .../apps/client/src/study/study.gateway.ts | 3 +- 4 files changed, 238 insertions(+), 49 deletions(-) create mode 100644 apps/backend/apps/client/src/study/study-room.processor.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index df496d17a3..11a2c9f8d1 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -5,9 +5,12 @@ export const RECONNECT_PREFIX = 'study-reconnect' export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` export const membersKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}:members` +export const reconnectKey = (groupId: number, userId: number) => + `${RECONNECT_PREFIX}:${groupId}:${userId}` // 상수 export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 +export const RECONNECT_GRACE_SEC = 30 // 20초 // Room export interface RoomState { @@ -45,7 +48,3 @@ export interface JoinResponse { export interface JoinPayload { groupId: number } - -export interface LeavePayload { - groupId: number -} diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts new file mode 100644 index 0000000000..084109ae80 --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -0,0 +1,30 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { Job } from 'bullmq' +import { + StudyRoomService, + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE +} from './study-room.service' + +@Processor(STUDY_ROOM_QUEUE) +export class StudyRoomProcessor extends WorkerHost { + private readonly logger = new Logger(StudyRoomProcessor.name) + + constructor(private readonly studyRoomService: StudyRoomService) { + super() + } + + async process(job: Job): Promise { + switch (job.name) { + case JOB_RECONNECT_EXPIRE: + await this.studyRoomService.handleReconnectExpiry(job.data) + break + default: + this.logger.warn( + `Unknown job name: ${job.name}`, + JSON.stringify(job.data) + ) + } + } +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 64ab835e0c..cc2dfe0462 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -1,11 +1,15 @@ +import { InjectQueue } from '@nestjs/bullmq' import { Inject, Injectable, Logger } from '@nestjs/common' +import type { Queue } from 'bullmq' import type Redis from 'ioredis' import type { Server, Socket } from 'socket.io' import { REDIS_CLIENT } from '@libs/redis' import { roomKey, + reconnectKey, membersKey, SESSION_DURATION_MS, + RECONNECT_GRACE_SEC, type RoomState, type RoomMember, type JoinResponse, @@ -13,13 +17,17 @@ import { } from './interface/study-socket.interface' import { StudyService } from './study.service' +export const STUDY_ROOM_QUEUE = 'study-room' +export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' + @Injectable() export class StudyRoomService { private readonly logger = new Logger(StudyRoomService.name) - private server: Server + private server!: Server constructor( @Inject(REDIS_CLIENT) private readonly redis: Redis, + @InjectQueue(STUDY_ROOM_QUEUE) private readonly queue: Queue, private readonly studyService: StudyService ) {} @@ -28,65 +36,52 @@ export class StudyRoomService { } /** - * 사용자 WebSocket을 스터디 룸에 입장시킨다. + * 사용자 WebSocket을 스터디 룸에 입장시킵니다. * - * 1. 사용자의 그룹 참여 권한 검증 - * 2. 재연결 복구 확인 - * 3. socket.data에 사용자 메타데이터 저장하고 room에 join() - * 4. Redis에서 룸 상태 생성 또는 조회 - * 5. 세션 종료 여부 검증 - * 6. Redis에 멤버 등록하고 전체 멤버 조회 - * 7. 첫번째 입장이면 room:started 이벤트 브로드케스트 - * 그외 입장이면 room:participantsChanged 이벤트 emit - * 8. 클라이언트에 현재 룸 상태 반환 + * 1. Redis 기준으로 사용자의 그룹 입장 가능 여부 확인 + * 2. 사용자의 스터디 그룹 참여 권한 검증 + * 3. Redis에서 룸 상태 생성 또는 기존 상태 조회하고 유효성 검증 + * 4. Socket.IO 룸 입장 및 Redis 멤버 정보 등록 + * 5. 입장 케이스에 따라 이벤트 emit + * - 첫 번째 입장이면 room:started 이벤트 브로드캐스트 + * - 재연결 입장이면 room:participantReconnected 이벤트 emit + * - 그 외 입장이면 room:participantsChanged 이벤트 emit + * 6. 클라이언트에 현재 룸 상태 반환 * * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID - * @returns 현재 룸 상태 + * @returns 입장 처리 결과와 현재 룸 상태 */ async join( client: Socket, groupId: number ): Promise> { const userId: number = client.data.userId + + const check = await this.checkJoinable(groupId, userId) + if (!check.ok) + return { success: false, code: check.code, message: check.message } + const membership = await this.studyService.validateJoinableStudyGroup( groupId, userId ) - // TODO: reconnect 확인 - - client.data = { - ...client.data, - userName: membership.userName, - isLeader: membership.isLeader, - groupId - } - client.join(roomKey(groupId)) - const now = Date.now() const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) - if (!state) { - client.leave(roomKey(groupId)) + if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - } - if (now >= state.endAt) { - client.leave(roomKey(groupId)) + if (state && now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } - } - await this.addMember(groupId, userId, { - userId, - socketId: client.id, - userName: membership.userName, - isLeader: membership.isLeader, - joinedAt: now - }) + const isRecovered = check.isRecovered + await this.enterRoom(client, groupId, userId, membership, now, isRecovered!) const members = await this.getMembers(groupId) if (isFirst) await this.onFirstJoin(groupId, state, members) + else if (isRecovered) this.onReconnectJoin(client, groupId, members) else this.onSubsequentJoin(client, groupId, members) return { @@ -102,12 +97,44 @@ export class StudyRoomService { } /** - * 스터디 룸을 생성하거나 기존 상태를 조회한다. + * 사용자가 해당 스터디 그룹 룸에 입장 가능한지 확인합니다. + * + * Redis의 멤버 목록과 reconnectKey 존재 여부를 조회하여 + * 이미 멤버로 등록되어 있으면서 재연결 대기 상태가 아니면 중복 입장으로 판단합니다. + * + * @param groupId 스터디 그룹 ID + * @param userId 사용자 ID + * @returns 입장 가능 여부와 재연결 여부 + */ + private async checkJoinable( + groupId: number, + userId: number + ): Promise<{ + ok: boolean + isRecovered?: boolean + code?: string + message?: string + }> { + const [existingMember, reconnectRaw] = await Promise.all([ + this.redis.hget(membersKey(groupId), String(userId)), + this.redis.get(reconnectKey(groupId, userId)) + ]) + + const isRecovered = !!reconnectRaw + + if (existingMember && !isRecovered) + return { ok: false, message: '이미 이 룸에 참여 중입니다.' } + + return { ok: true, isRecovered } + } + + /** + * 스터디 룸을 생성하거나 기존 상태를 조회합니다. * * 1. 현재 시간을 기준으로 RoomState 상태를 생성 * 2. Redis에 SET NX로 roomKey 저장 시도 * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 - * - 성공이면, 첫번째 입장자로 -> 생성한 activeState 사용 + * - 성공이면, 첫 번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 * * @param {number} groupId 스터디 그룹 ID @@ -129,6 +156,8 @@ export class StudyRoomService { const isFirst = !!(await this.redis.set( roomKey(groupId), JSON.stringify(activeState), + 'PX', + SESSION_DURATION_MS + 60_000, 'NX' )) @@ -136,6 +165,52 @@ export class StudyRoomService { return { state, isFirst } } + /** + * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. + * + * 1. 재연결 입장인 경우 Redis의 재연결 대기 키를 삭제하고 예약되어 있던 재연결 만료 작업 취소 + * 2. socket.data에 사용자 메타데이터를 저장 + * 3. Socket.IO 룸에 참여 + * 4. Redis 멤버 목록에 현재 소켓 정보 등록 + * + * @param client 연결된 Socket 인스턴스 + * @param groupId 스터디 그룹 ID + * @param userId 사용자 ID + * @param membership 사용자 이름과 리더 여부 + * @param now 입장 시각(ms) + * @param isRecovered 재연결 복구 여부 + */ + private async enterRoom( + client: Socket, + groupId: number, + userId: number, + membership: { userName: string; isLeader: boolean }, + now: number, + isRecovered: boolean + ): Promise { + if (isRecovered) { + await this.redis.del(reconnectKey(groupId, userId)) + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() + } + client.data = { + ...client.data, + userName: membership.userName, + isLeader: membership.isLeader, + groupId + } + client.join(roomKey(groupId)) + + await this.addMember(groupId, userId, { + userId, + socketId: client.id, + userName: membership.userName, + isLeader: membership.isLeader, + joinedAt: now + }) + } /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * @@ -157,16 +232,12 @@ export class StudyRoomService { endAt: state.endAt, members }) - - this.logger.log( - `room start🚀 : groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` - ) } /** * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. * - * @param {Socket} client 현재 소켓 + * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @param {RoomMember[]} members 최신 참여자 목록 */ @@ -178,6 +249,24 @@ export class StudyRoomService { client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) } + /** + * 재연결된 사용자가 들어왔을 때, 기존 참여자들에게 재연결 완료 사실을 알립니다. + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 + */ + private onReconnectJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + this.server.to(roomKey(groupId)).emit('room:participantReconnected', { + userId: client.data.userId, + userName: client.data.userName, + members + }) + } /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * @@ -186,7 +275,7 @@ export class StudyRoomService { * 3. Socket.io 룸에서 클라이언트를 제외(leave) * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * - * @param {Socket} client 현재 소켓 + * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @returns 퇴장 성공 여부 */ @@ -202,10 +291,80 @@ export class StudyRoomService { members: await this.getMembers(groupId) }) - this.logger.log(`leave 👋 : userId=${userId}, groupId=${groupId}`) return { success: true } } + /** + * 웹소켓 연결이 끊어졌을 때 호출되며, 재연결 유예 기간(Grace Period)을 부여합니다. + * + * 1. Redis에 재연결 대기 상태 키 저장 + * 2. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 + * 3. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 + * + * @param {Socket} client 연결된 Socket 인스턴스 + */ + async handleDisconnect(client: Socket): Promise { + const { userId, groupId, userName } = client.data ?? {} + if (!userId || !groupId) return + + await this.redis.set( + reconnectKey(groupId, userId), + '1', + 'EX', + RECONNECT_GRACE_SEC + 10 + ) + + const jobId = `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + const existingJob = await this.queue.getJob(jobId) + await existingJob?.remove() + + await this.queue.add( + JOB_RECONNECT_EXPIRE, + { userId, groupId, userName }, + { + jobId, + delay: RECONNECT_GRACE_SEC * 1000, + removeOnComplete: true, + removeOnFail: true + } + ) + + this.server + .to(roomKey(groupId)) + .emit('room:participantReconnecting', { userId, userName }) + } + + /** + * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. + * BullMQ Processor에서 호출됩니다. + * + * 1. Redis에서 재연결 대기 상태 키 확인 + * 2. 키가 없으면 이미 복구된 것으로 보고 중단 + * 3. 대기 상태 키와 멤버 정보를 삭제 + * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * + * @param payload + * @param {number} payload.userId 사용자 ID + * @param {number} payload.groupId 스터디 그룹 ID + */ + async handleReconnectExpiry(payload: { + userId: number + groupId: number + }): Promise { + const { userId, groupId } = payload + + const stillDisconnected = await this.redis.get( + reconnectKey(groupId, userId) + ) + if (!stillDisconnected) return + await this.redis.del(reconnectKey(groupId, userId)) + await this.removeMember(groupId, userId) + + this.server.to(roomKey(groupId)).emit('room:participantsChanged', { + members: await this.getMembers(groupId) + }) + } + // Redis /** * Redis에서 룸 상태를 조회합니다. diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 14a25874eb..c8354b2d4c 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -49,8 +49,9 @@ export class StudyGateway } // 클라이언트 연결 해제 - handleDisconnect(client: Socket) { + async handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: socketId=${client.id}`) + await this.studyRoomService.handleDisconnect(client) } // Room From 6e03654f2dd7f6bb766cc4ee92ed0354b226d93b Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 17:54:11 +0000 Subject: [PATCH 54/72] refactor(be): use socket data instead of payload for room:leave --- .../apps/client/src/study/study.gateway.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index c8354b2d4c..0c524fde33 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -12,10 +12,7 @@ import { } from '@nestjs/websockets' import type { Server, Socket } from 'socket.io' import { JwtAuthGuard } from '@libs/auth' -import type { - JoinPayload, - LeavePayload -} from './interface/study-socket.interface' +import type { JoinPayload } from './interface/study-socket.interface' import { StudyRoomService } from './study-room.service' @UseGuards(JwtAuthGuard) @@ -61,17 +58,16 @@ export class StudyGateway @MessageBody() payload: JoinPayload ) { const { userId, groupId } = this.parsePayload(client, payload?.groupId) - console.log(`🏠room:join 시작: userId=${userId}, groupId=${groupId}`) return this.studyRoomService.join(client, groupId) } @SubscribeMessage('room:leave') - async handleLeave( - @ConnectedSocket() client: Socket, - @MessageBody() payload: LeavePayload - ) { - const { userId, groupId } = this.parsePayload(client, payload?.groupId) - console.log(`🏠room:leave 시작: userId=${userId}, groupId=${groupId}`) + async handleLeave(@ConnectedSocket() client: Socket) { + const userId = client.data.user?.id ?? client.data.userId + if (!userId) throw new WsException('Unauthorized') + const groupId = client.data.groupId + if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') + return this.studyRoomService.leave(client, groupId) } From 2fe4aaf552db3a5865f5c6428651aa8788aa87c2 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 20:26:03 +0000 Subject: [PATCH 55/72] feat(be): implement room lifecycle handling with BullMQ --- .../study/interface/study-socket.interface.ts | 5 +- .../client/src/study/study-room.processor.ts | 13 +- .../client/src/study/study-room.service.ts | 132 ++++++++++++++++-- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 11a2c9f8d1..d323b61041 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -10,7 +10,9 @@ export const reconnectKey = (groupId: number, userId: number) => // 상수 export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 -export const RECONNECT_GRACE_SEC = 30 // 20초 +export const RECONNECT_GRACE_SEC = 30 // 30초 +export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 +export const BUFFER_SEC = 10 // 10초 // Room export interface RoomState { @@ -19,7 +21,6 @@ export interface RoomState { hostUserId: number } -// Member export interface RoomMember { userId: number socketId: string diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts index 084109ae80..56dddcfaab 100644 --- a/apps/backend/apps/client/src/study/study-room.processor.ts +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -4,7 +4,9 @@ import { Job } from 'bullmq' import { StudyRoomService, STUDY_ROOM_QUEUE, - JOB_RECONNECT_EXPIRE + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END } from './study-room.service' @Processor(STUDY_ROOM_QUEUE) @@ -17,9 +19,18 @@ export class StudyRoomProcessor extends WorkerHost { async process(job: Job): Promise { switch (job.name) { + case JOB_ROOM_REMINDER: + await this.studyRoomService.reminderRoom(job.data.groupId) + break + + case JOB_ROOM_END: + await this.studyRoomService.endRoom(job.data.groupId) + break + case JOB_RECONNECT_EXPIRE: await this.studyRoomService.handleReconnectExpiry(job.data) break + default: this.logger.warn( `Unknown job name: ${job.name}`, diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index cc2dfe0462..b774cbf535 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -10,6 +10,8 @@ import { membersKey, SESSION_DURATION_MS, RECONNECT_GRACE_SEC, + REMINDER_BEFORE_END_MS, + BUFFER_SEC, type RoomState, type RoomMember, type JoinResponse, @@ -19,6 +21,8 @@ import { StudyService } from './study.service' export const STUDY_ROOM_QUEUE = 'study-room' export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' +export const JOB_ROOM_REMINDER = 'room-reminder' +export const JOB_ROOM_END = 'room-end' @Injectable() export class StudyRoomService { @@ -90,7 +94,7 @@ export class StudyRoomService { members, startedAt: state.startedAt, endAt: state.endAt, - remainMs: state.endAt - now, + remainMs: Math.max(0, state.endAt - now), hostUserId: state.hostUserId } } @@ -102,8 +106,8 @@ export class StudyRoomService { * Redis의 멤버 목록과 reconnectKey 존재 여부를 조회하여 * 이미 멤버로 등록되어 있으면서 재연결 대기 상태가 아니면 중복 입장으로 판단합니다. * - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID * @returns 입장 가능 여부와 재연결 여부 */ private async checkJoinable( @@ -173,12 +177,12 @@ export class StudyRoomService { * 3. Socket.IO 룸에 참여 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 * - * @param client 연결된 Socket 인스턴스 - * @param groupId 스터디 그룹 ID - * @param userId 사용자 ID - * @param membership 사용자 이름과 리더 여부 - * @param now 입장 시각(ms) - * @param isRecovered 재연결 복구 여부 + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 + * @param {number} now 입장 시각(ms) + * @param {boolean} isRecovered 재연결 복구 여부 */ private async enterRoom( client: Socket, @@ -195,6 +199,7 @@ export class StudyRoomService { ) await job?.remove() } + client.data = { ...client.data, userName: membership.userName, @@ -211,6 +216,7 @@ export class StudyRoomService { joinedAt: now }) } + /** * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. * @@ -223,9 +229,7 @@ export class StudyRoomService { state: RoomState, members: RoomMember[] ): Promise { - // 첫 번째: 타이머 시작 + 룸 전체(본인 포함)에 started emit - - // TODO: await this.scheduleJobs() + await this.scheduleJobs(groupId, state.endAt) this.server.to(roomKey(groupId)).emit('room:started', { startedAt: state.startedAt, @@ -267,6 +271,55 @@ export class StudyRoomService { members }) } + + /** + * 세션 시작 시 종료 리마인드 작업과 종료 작업을 BullMQ에 예약합니다. + * + * - room-reminder:{groupId}: 세션 종료 REMINDER_BEFORE_END_MS 전 실행 + * - room-end:{groupId}: 세션 종료 시점에 실행 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} endAt + */ + private async scheduleJobs(groupId: number, endAt: number): Promise { + const now = Date.now() + const delayToEnd = endAt - now + const delayToReminder = delayToEnd - REMINDER_BEFORE_END_MS + + const reminderJobId = `${JOB_ROOM_REMINDER}:${groupId}` + const endJobId = `${JOB_ROOM_END}:${groupId}` + + const [existingReminder, existingEnd] = await Promise.all([ + this.queue.getJob(reminderJobId), + this.queue.getJob(endJobId) + ]) + await Promise.all([existingReminder?.remove(), existingEnd?.remove()]) + + if (delayToReminder > 0) { + await this.queue.add( + JOB_ROOM_REMINDER, + { groupId }, + { + jobId: reminderJobId, + delay: delayToReminder, + removeOnComplete: true, + removeOnFail: true + } + ) + } + + await this.queue.add( + JOB_ROOM_END, + { groupId }, + { + jobId: endJobId, + delay: delayToEnd, + removeOnComplete: true, + removeOnFail: true + } + ) + } + /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * @@ -311,7 +364,7 @@ export class StudyRoomService { reconnectKey(groupId, userId), '1', 'EX', - RECONNECT_GRACE_SEC + 10 + RECONNECT_GRACE_SEC + BUFFER_SEC ) const jobId = `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` @@ -334,6 +387,59 @@ export class StudyRoomService { .emit('room:participantReconnecting', { userId, userName }) } + // BullMQ Processor 호출 + /** + * 세션 종료 전 리마인드 이벤트를 룸 전체에 전송합니다. + * BullMQ Processor에서 예약된 room-reminder 작업이 실행될 때 호출됩니다. + * + * @param {number} groupId 스터디 그룹 ID + */ + async reminderRoom(groupId: number): Promise { + const state = await this.getRoomState(groupId) + if (!state) return + + this.server.to(roomKey(groupId)).emit('room:reminder', { + groupId, + endAt: state.endAt, + remainMs: Math.max(0, state.endAt - Date.now()) + }) + } + + /** + * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. + * + * 1. 룸 전체에 room:ended 이벤트를 전송 + * 2. 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 + * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 + * + * @param {number} groupId 스터디 그룹 ID + */ + async endRoom(groupId: number): Promise { + this.server.to(roomKey(groupId)).emit('room:ended', { groupId }) + + const members = await this.getMembers(groupId) + await Promise.all( + members.map((m) => this.redis.del(reconnectKey(groupId, m.userId))) + ) + + const sockets = await this.server.in(roomKey(groupId)).fetchSockets() + await Promise.all( + sockets.map((s) => { + Object.assign(s.data, { + groupId: undefined, + userId: undefined, + userName: undefined + }) + return s.leave(roomKey(groupId)) + }) + ) + + await Promise.all([ + this.redis.del(roomKey(groupId)), + this.redis.del(membersKey(groupId)) + ]) + } + /** * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. * BullMQ Processor에서 호출됩니다. From c95cbaa8e9e62f6aa4c7c8ff604cd3c8b0f1b08b Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 21:13:48 +0000 Subject: [PATCH 56/72] fix(be): resolve race conditions in reconnection and handle expired sessions --- .../client/src/study/study-room.service.ts | 44 +++++++++++-------- .../apps/client/src/study/study.gateway.ts | 4 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index b774cbf535..ef3def1330 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -79,8 +79,8 @@ export class StudyRoomService { if (state && now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } - const isRecovered = check.isRecovered - await this.enterRoom(client, groupId, userId, membership, now, isRecovered!) + const isRecovered = check.isRecovered ?? false + await this.enterRoom(client, groupId, userId, membership, now, isRecovered) const members = await this.getMembers(groupId) @@ -172,7 +172,7 @@ export class StudyRoomService { /** * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. * - * 1. 재연결 입장인 경우 Redis의 재연결 대기 키를 삭제하고 예약되어 있던 재연결 만료 작업 취소 + * 1. 재연결 입장인 경우 reconnectKey 삭제를 시도하고, 예약되어 있던 재연결 만료 작업 취소 * 2. socket.data에 사용자 메타데이터를 저장 * 3. Socket.IO 룸에 참여 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 @@ -193,11 +193,13 @@ export class StudyRoomService { isRecovered: boolean ): Promise { if (isRecovered) { - await this.redis.del(reconnectKey(groupId, userId)) - const job = await this.queue.getJob( - `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` - ) - await job?.remove() + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (deleted) { + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() + } } client.data = { @@ -275,6 +277,7 @@ export class StudyRoomService { /** * 세션 시작 시 종료 리마인드 작업과 종료 작업을 BullMQ에 예약합니다. * + * - delayToEnd <= 0이면 즉시 endRoom 호출 (이미 종료됐어야 하는 룸 처리) * - room-reminder:{groupId}: 세션 종료 REMINDER_BEFORE_END_MS 전 실행 * - room-end:{groupId}: 세션 종료 시점에 실행 * @@ -295,6 +298,11 @@ export class StudyRoomService { ]) await Promise.all([existingReminder?.remove(), existingEnd?.remove()]) + if (delayToEnd <= 0) { + await this.endRoom(groupId) + return + } + if (delayToReminder > 0) { await this.queue.add( JOB_ROOM_REMINDER, @@ -359,6 +367,7 @@ export class StudyRoomService { async handleDisconnect(client: Socket): Promise { const { userId, groupId, userName } = client.data ?? {} if (!userId || !groupId) return + client.data.groupId = undefined await this.redis.set( reconnectKey(groupId, userId), @@ -409,7 +418,7 @@ export class StudyRoomService { * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. * * 1. 룸 전체에 room:ended 이벤트를 전송 - * 2. 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 + * 2. 연결 중인 유저 Key 삭제 후 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 * * @param {number} groupId 스터디 그룹 ID @@ -444,10 +453,11 @@ export class StudyRoomService { * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. * BullMQ Processor에서 호출됩니다. * - * 1. Redis에서 재연결 대기 상태 키 확인 - * 2. 키가 없으면 이미 복구된 것으로 보고 중단 - * 3. 대기 상태 키와 멤버 정보를 삭제 - * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 1. reconnectKey를 원자적으로 삭제 (DEL 반환값으로 복구 여부 판단) + * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 + * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 + * 2. Redis 멤버 목록에서 해당 사용자 삭제 + * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -459,11 +469,9 @@ export class StudyRoomService { }): Promise { const { userId, groupId } = payload - const stillDisconnected = await this.redis.get( - reconnectKey(groupId, userId) - ) - if (!stillDisconnected) return - await this.redis.del(reconnectKey(groupId, userId)) + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (!deleted) return + await this.removeMember(groupId, userId) this.server.to(roomKey(groupId)).emit('room:participantsChanged', { diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 0c524fde33..0e056b2488 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -63,7 +63,7 @@ export class StudyGateway @SubscribeMessage('room:leave') async handleLeave(@ConnectedSocket() client: Socket) { - const userId = client.data.user?.id ?? client.data.userId + const userId = client.data.userId if (!userId) throw new WsException('Unauthorized') const groupId = client.data.groupId if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') @@ -82,7 +82,7 @@ export class StudyGateway client: Socket, rawGroupId: unknown ): { userId: number; groupId: number } { - const userId = client.data.user?.id ?? client.data.userId + const userId = client.data.userId if (!userId) throw new WsException('Unauthorized') const groupId = this.parsePositiveInt(rawGroupId) From b78f66a611441a4ad8a0f61d911d3b91b941b9c2 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 21:24:54 +0000 Subject: [PATCH 57/72] chore(be): refine logging and clean up unused code --- .../client/src/study/study-room.service.ts | 10 +++++++- .../apps/client/src/study/study.gateway.ts | 23 +++---------------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index ef3def1330..2a5a07a4f6 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -63,8 +63,12 @@ export class StudyRoomService { const userId: number = client.data.userId const check = await this.checkJoinable(groupId, userId) - if (!check.ok) + if (!check.ok) { + this.logger.warn( + `입장 거부: userId=${userId}, groupId=${groupId}, reason=${check.message}` + ) return { success: false, code: check.code, message: check.message } + } const membership = await this.studyService.validateJoinableStudyGroup( groupId, @@ -233,6 +237,10 @@ export class StudyRoomService { ): Promise { await this.scheduleJobs(groupId, state.endAt) + this.logger.log( + `룸 시작: groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` + ) + this.server.to(roomKey(groupId)).emit('room:started', { startedAt: state.startedAt, endAt: state.endAt, diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 0e056b2488..3b384bbd25 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -3,7 +3,6 @@ import { WebSocketGateway, WebSocketServer, OnGatewayInit, - OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, ConnectedSocket, @@ -25,9 +24,7 @@ import { StudyRoomService } from './study-room.service' credentials: true } }) -export class StudyGateway - implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect -{ +export class StudyGateway implements OnGatewayInit, OnGatewayDisconnect { @WebSocketServer() server: Server @@ -37,17 +34,10 @@ export class StudyGateway afterInit(server: Server) { this.studyRoomService.setServer(server) - this.logger.log('✅ StudyGateway initialized') + this.logger.log('StudyGateway initialized') } - // 클라이언트 연결 - handleConnection(client: Socket) { - this.logger.log(`Client connected: socketId=${client.id}`) - } - - // 클라이언트 연결 해제 async handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: socketId=${client.id}`) await this.studyRoomService.handleDisconnect(client) } @@ -57,7 +47,7 @@ export class StudyGateway @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const { userId, groupId } = this.parsePayload(client, payload?.groupId) + const { groupId } = this.parsePayload(client, payload?.groupId) return this.studyRoomService.join(client, groupId) } @@ -71,13 +61,6 @@ export class StudyGateway return this.studyRoomService.leave(client, groupId) } - // ✅ 연결 확인용 ping - // @SubscribeMessage('ping') - // handlePing(@ConnectedSocket() client: Socket, @MessageBody() data: unknown) { - // this.logger.log(`Ping from ${client.id}`) - // return { event: 'pong', data: '✅ Gateway connected!' } - // } - private parsePayload( client: Socket, rawGroupId: unknown From 4834b383e4e8b093680c6245a2c803f9f3906ac3 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sat, 25 Apr 2026 22:10:39 +0000 Subject: [PATCH 58/72] chore(be): remove unused res field and simplify socket leave logic --- .../apps/client/src/study/study-room.service.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 2a5a07a4f6..3135f7934e 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -67,7 +67,7 @@ export class StudyRoomService { this.logger.warn( `입장 거부: userId=${userId}, groupId=${groupId}, reason=${check.message}` ) - return { success: false, code: check.code, message: check.message } + return { success: false, message: check.message } } const membership = await this.studyService.validateJoinableStudyGroup( @@ -440,16 +440,7 @@ export class StudyRoomService { ) const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all( - sockets.map((s) => { - Object.assign(s.data, { - groupId: undefined, - userId: undefined, - userName: undefined - }) - return s.leave(roomKey(groupId)) - }) - ) + await Promise.all(sockets.map((s) => s.leave(roomKey(groupId)))) await Promise.all([ this.redis.del(roomKey(groupId)), From 59490c6e8c49937950b65ed607df18877f95d132 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 26 Apr 2026 16:28:26 +0000 Subject: [PATCH 59/72] feat(be): reassign host on host leave --- .../client/src/study/study-room.service.ts | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3135f7934e..3fad9f0088 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -80,7 +80,7 @@ export class StudyRoomService { if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - if (state && now >= state.endAt) + if (now >= state.endAt) return { success: false, message: '세션이 이미 종료되었습니다.' } const isRecovered = check.isRecovered ?? false @@ -342,7 +342,8 @@ export class StudyRoomService { * 1. client.data에서 groupId를 초기화 * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 3. Socket.io 룸에서 클라이언트를 제외(leave) - * 4. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 4. 호스트 퇴장 시 새 호스트 지정 + * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID @@ -356,9 +357,11 @@ export class StudyRoomService { await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) - this.server.to(roomKey(groupId)).emit('room:participantsChanged', { - members: await this.getMembers(groupId) - }) + const members = await this.transferHostIfNeeded(groupId, userId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) return { success: true } } @@ -456,7 +459,8 @@ export class StudyRoomService { * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 * 2. Redis 멤버 목록에서 해당 사용자 삭제 - * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 3. 호스트 퇴장 시 새 호스트 지정 + * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -473,9 +477,46 @@ export class StudyRoomService { await this.removeMember(groupId, userId) - this.server.to(roomKey(groupId)).emit('room:participantsChanged', { - members: await this.getMembers(groupId) - }) + const members = await this.transferHostIfNeeded(groupId, userId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) + } + + /** + * 호스트가 퇴장했을 때 새 호스트를 지정합니다. + * 우선순위: isLeader인 멤버 → 없으면 joinedAt 기준 가장 먼저 입장한 멤버 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} leavingUserId 퇴장한 호스트 userId + * @returns 스터디 룸에 남아있는 사용자들 + */ + private async transferHostIfNeeded( + groupId: number, + leavingUserId: number + ): Promise { + const state = await this.getRoomState(groupId) + const members = await this.getMembers(groupId) + + if (state && state.hostUserId === leavingUserId && members.length > 0) { + const newHost = + members.find((m) => m.isLeader) ?? + [...members].sort((a, b) => a.joinedAt - b.joinedAt)[0] + + await this.redis.set( + roomKey(groupId), + JSON.stringify({ ...state, hostUserId: newHost.userId }), + 'KEEPTTL' + ) + + this.server.to(roomKey(groupId)).emit('room:hostChanged', { + hostUserId: newHost.userId, + hostUserName: newHost.userName + }) + } + + return members } // Redis From b5951bad88db4c169250f48a6e1de99fcc1d89c7 Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 5 May 2026 16:44:33 +0000 Subject: [PATCH 60/72] refactor(be): use DB endTime for study room expiration --- .../study/interface/study-socket.interface.ts | 5 +--- .../client/src/study/study-room.service.ts | 25 +++++++++++-------- .../apps/client/src/study/study.service.ts | 12 ++++++--- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index d323b61041..3b058e3a19 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -9,15 +9,13 @@ export const reconnectKey = (groupId: number, userId: number) => `${RECONNECT_PREFIX}:${groupId}:${userId}` // 상수 -export const SESSION_DURATION_MS = 2 * 60 * 60 * 1000 // 2시간 export const RECONNECT_GRACE_SEC = 30 // 30초 export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 export const BUFFER_SEC = 10 // 10초 // Room export interface RoomState { - startedAt: number // Unix ms - endAt: number // Unix ms + endAt: number hostUserId: number } @@ -39,7 +37,6 @@ export interface SocketResponse { export interface JoinResponse { members: RoomMember[] - startedAt: number endAt: number remainMs: number hostUserId: number diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3fad9f0088..07e58e7a18 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -8,7 +8,6 @@ import { roomKey, reconnectKey, membersKey, - SESSION_DURATION_MS, RECONNECT_GRACE_SEC, REMINDER_BEFORE_END_MS, BUFFER_SEC, @@ -76,12 +75,20 @@ export class StudyRoomService { ) const now = Date.now() - const { state, isFirst } = await this.initOrGetRoom(groupId, userId, now) + + if (membership.endTime.getTime() <= now) { + return { success: false, message: '세션이 이미 종료되었습니다.' } + } + + const { state, isFirst } = await this.initOrGetRoom( + groupId, + userId, + now, + membership.endTime + ) if (!state) return { success: false, message: '룸 상태 가져오지 못했습니다.' } - if (now >= state.endAt) - return { success: false, message: '세션이 이미 종료되었습니다.' } const isRecovered = check.isRecovered ?? false await this.enterRoom(client, groupId, userId, membership, now, isRecovered) @@ -96,7 +103,6 @@ export class StudyRoomService { success: true, data: { members, - startedAt: state.startedAt, endAt: state.endAt, remainMs: Math.max(0, state.endAt - now), hostUserId: state.hostUserId @@ -153,11 +159,11 @@ export class StudyRoomService { private async initOrGetRoom( groupId: number, userId: number, - now: number + now: number, + endTime: Date ): Promise<{ state: RoomState | null; isFirst: boolean }> { const activeState: RoomState = { - startedAt: now, - endAt: now + SESSION_DURATION_MS, + endAt: endTime.getTime(), hostUserId: userId } @@ -165,7 +171,7 @@ export class StudyRoomService { roomKey(groupId), JSON.stringify(activeState), 'PX', - SESSION_DURATION_MS + 60_000, + endTime.getTime() - now + 60_000, 'NX' )) @@ -242,7 +248,6 @@ export class StudyRoomService { ) this.server.to(roomKey(groupId)).emit('room:started', { - startedAt: state.startedAt, endAt: state.endAt, members }) diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 63107d7926..43096365d5 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -447,7 +447,7 @@ export class StudyService { * * @param {number} groupId 그룹ID * @param {number} userId 유저ID - * @returns 사용자의 ID, 이름, 리더 여부를 포함한 객체 + * @returns 사용자의 ID, 이름, 리더 여부, 만료 시간을 포함한 객체 * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 */ @@ -464,8 +464,13 @@ export class StudyService { userId: true, isGroupLeader: true, user: { + select: { username: true } + }, + group: { select: { - username: true + studyInfo: { + select: { endTime: true } + } } } } @@ -478,7 +483,8 @@ export class StudyService { return { userId: membership.userId, userName: membership.user.username, - isLeader: membership.isGroupLeader + isLeader: membership.isGroupLeader, + endTime: membership.group.studyInfo!.endTime } } } From fb1dc28bf5b0a1c6fb2f3a3bbd73216a2f8be1fb Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 5 May 2026 18:21:25 +0000 Subject: [PATCH 61/72] refactor(be): standardize key prefix structure for study --- .../src/study/interface/study-socket.interface.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 3b058e3a19..041a94ff46 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,12 +1,15 @@ // Redis Key -export const ROOM_PREFIX = 'study-room' -export const RECONNECT_PREFIX = 'study-reconnect' +export const STUDY_KEY_PREFIX = 'study' +export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` +export const STUDY_CHAT_PREFIX = `${STUDY_KEY_PREFIX}:chat` -export const roomKey = (groupId: number) => `${ROOM_PREFIX}:${groupId}` +export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` export const membersKey = (groupId: number) => - `${ROOM_PREFIX}:${groupId}:members` + `${STUDY_ROOM_PREFIX}:${groupId}:members` export const reconnectKey = (groupId: number, userId: number) => - `${RECONNECT_PREFIX}:${groupId}:${userId}` + `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` + +export const chatKey = (groupId: number) => `${STUDY_CHAT_PREFIX}:${groupId}` // 상수 export const RECONNECT_GRACE_SEC = 30 // 30초 From 0d57c8e041126cf82b98202f6e4653354a1b2c86 Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 12 May 2026 15:51:19 +0000 Subject: [PATCH 62/72] refactor(be): add safe Redis member parsing with error handling --- .../apps/client/src/study/study-room.service.ts | 15 +++++++++++---- apps/backend/libs/redis/src/redis.module.ts | 1 - 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 07e58e7a18..d12991f240 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -38,6 +38,7 @@ export class StudyRoomService { this.server = server } + // Room /** * 사용자 WebSocket을 스터디 룸에 입장시킵니다. * @@ -351,11 +352,10 @@ export class StudyRoomService { * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 - * @param {number} groupId 스터디 그룹 ID * @returns 퇴장 성공 여부 */ - async leave(client: Socket, groupId: number): Promise { - const { userId } = client.data + async leave(client: Socket): Promise { + const { userId, groupId } = client.data client.data.groupId = undefined @@ -574,6 +574,13 @@ export class StudyRoomService { async getMembers(groupId: number): Promise { const raw = await this.redis.hgetall(membersKey(groupId)) if (!raw) return [] - return Object.values(raw).map((v) => JSON.parse(v) as RoomMember) + return Object.values(raw).flatMap((v) => { + try { + return [JSON.parse(v) as RoomMember] + } catch { + this.logger.error(`멤버 데이터 파싱 실패`) + return [] + } + }) } } diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts index 57c19a9b86..0fc8d3ce11 100644 --- a/apps/backend/libs/redis/src/redis.module.ts +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -30,7 +30,6 @@ export const REDIS_CLIENT = 'REDIS_CLIENT' throw new Error(`Redis connection failed: ${error.message}`) } - // console.log('✅ Redis connected') return redis }, inject: [ConfigService] From 10a0c70c9d5db0d02114b805c260ecbb6d87d197 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:41:43 +0000 Subject: [PATCH 63/72] refactor(be): normalize socket data structure for study room --- .../client/src/study/study-room.service.ts | 29 +++++++++++------ .../apps/client/src/study/study.gateway.ts | 31 +++++++------------ .../libs/auth/src/jwt/jwt-auth.guard.ts | 3 -- 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index d12991f240..c22cdd7c2a 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -60,7 +60,7 @@ export class StudyRoomService { client: Socket, groupId: number ): Promise> { - const userId: number = client.data.userId + const userId: number = client.data.user.id const check = await this.checkJoinable(groupId, userId) if (!check.ok) { @@ -213,8 +213,7 @@ export class StudyRoomService { } } - client.data = { - ...client.data, + client.data.room = { userName: membership.userName, isLeader: membership.isLeader, groupId @@ -282,8 +281,8 @@ export class StudyRoomService { members: RoomMember[] ): void { this.server.to(roomKey(groupId)).emit('room:participantReconnected', { - userId: client.data.userId, - userName: client.data.userName, + userId: client.data.user.id, + userName: client.data.room.userName, members }) } @@ -355,9 +354,10 @@ export class StudyRoomService { * @returns 퇴장 성공 여부 */ async leave(client: Socket): Promise { - const { userId, groupId } = client.data + const userId = client.data.user.id + const groupId = client.data.room.groupId - client.data.groupId = undefined + client.data.room = undefined await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) @@ -381,9 +381,13 @@ export class StudyRoomService { * @param {Socket} client 연결된 Socket 인스턴스 */ async handleDisconnect(client: Socket): Promise { - const { userId, groupId, userName } = client.data ?? {} + const userId = client.data.user?.id + + const groupId = client.data.room?.groupId if (!userId || !groupId) return - client.data.groupId = undefined + + const userName = client.data.room.userName + client.data.room = undefined await this.redis.set( reconnectKey(groupId, userId), @@ -448,7 +452,12 @@ export class StudyRoomService { ) const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all(sockets.map((s) => s.leave(roomKey(groupId)))) + await Promise.all( + sockets.map((s) => { + s.data.room = undefined + s.leave(roomKey(groupId)) + }) + ) await Promise.all([ this.redis.del(roomKey(groupId)), diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 3b384bbd25..3b47d76d81 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -47,36 +47,27 @@ export class StudyGateway implements OnGatewayInit, OnGatewayDisconnect { @ConnectedSocket() client: Socket, @MessageBody() payload: JoinPayload ) { - const { groupId } = this.parsePayload(client, payload?.groupId) + const groupId = this.parseGroupId(payload?.groupId) return this.studyRoomService.join(client, groupId) } @SubscribeMessage('room:leave') async handleLeave(@ConnectedSocket() client: Socket) { - const userId = client.data.userId - if (!userId) throw new WsException('Unauthorized') - const groupId = client.data.groupId - if (!groupId) throw new WsException('룸에 참여 중이 아닙니다.') - - return this.studyRoomService.leave(client, groupId) + this.assertInRoom(client) + return this.studyRoomService.leave(client) } - private parsePayload( - client: Socket, - rawGroupId: unknown - ): { userId: number; groupId: number } { - const userId = client.data.userId - if (!userId) throw new WsException('Unauthorized') + private parseGroupId(rawGroupId: unknown): number { + const groupId = Number(rawGroupId) - const groupId = this.parsePositiveInt(rawGroupId) - if (!groupId) throw new WsException('groupId must be a positive integer') + if (!Number.isInteger(groupId) || groupId <= 0) + throw new WsException('groupId must be a positive integer') - return { userId, groupId } + return groupId } - private parsePositiveInt(value: unknown): number | null { - const num = Number(value) - if (!Number.isInteger(num) || num <= 0) return null - return num + private assertInRoom(client: Socket): void { + if (!client.data.room?.groupId) + throw new WsException('룸에 참여 중이 아닙니다.') } } diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index d41bebfa5b..cef98384f0 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -69,9 +69,6 @@ export class JwtAuthGuard extends AuthGuard('jwt') { throw err || new UnauthorizedException() } client.data.user = user - client.data.userId = user.id ?? user.userId - client.data.userName = user.username - client.data.userRole = user.role ?? user.userRole return user } From 468f0ed66c431c5d243c291532d73a2a40e43d21 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:45:22 +0000 Subject: [PATCH 64/72] refactor(be): improve study room join validation and lifecycle --- .../client/src/study/study-room.service.ts | 27 +++++++++++++------ .../apps/client/src/study/study.service.ts | 7 ++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index c22cdd7c2a..ab6a826213 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -70,10 +70,15 @@ export class StudyRoomService { return { success: false, message: check.message } } - const membership = await this.studyService.validateJoinableStudyGroup( - groupId, - userId - ) + let membership + try { + membership = await this.studyService.validateJoinableStudyGroup( + groupId, + userId + ) + } catch { + return { success: false, message: '입장 권한이 없습니다.' } + } const now = Date.now() @@ -94,6 +99,12 @@ export class StudyRoomService { const isRecovered = check.isRecovered ?? false await this.enterRoom(client, groupId, userId, membership, now, isRecovered) + if (isFirst) + await this.redis.expireat( + membersKey(groupId), + Math.ceil((membership.endTime.getTime() + 60_000) / 1000) + ) + const members = await this.getMembers(groupId) if (isFirst) await this.onFirstJoin(groupId, state, members) @@ -105,7 +116,7 @@ export class StudyRoomService { data: { members, endAt: state.endAt, - remainMs: Math.max(0, state.endAt - now), + remainMs: Math.max(0, state.endAt - Date.now()), hostUserId: state.hostUserId } } @@ -130,14 +141,14 @@ export class StudyRoomService { code?: string message?: string }> { - const [existingMember, reconnectRaw] = await Promise.all([ - this.redis.hget(membersKey(groupId), String(userId)), + const [memberExists, reconnectRaw] = await Promise.all([ + this.redis.hexists(membersKey(groupId), String(userId)), this.redis.get(reconnectKey(groupId, userId)) ]) const isRecovered = !!reconnectRaw - if (existingMember && !isRecovered) + if (memberExists && !isRecovered) return { ok: false, message: '이미 이 룸에 참여 중입니다.' } return { ok: true, isRecovered } diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 43096365d5..e26af381d6 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -450,6 +450,7 @@ export class StudyService { * @returns 사용자의 ID, 이름, 리더 여부, 만료 시간을 포함한 객체 * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 + * - 해당 스터디 그룹의 endTime이 없을 경우 발생 */ async validateJoinableStudyGroup(groupId: number, userId: number) { const membership = await this.prisma.userGroup.findUnique({ @@ -480,11 +481,15 @@ export class StudyService { throw new ForbiddenAccessException('User ID is not in the group') } + if (!membership.group.studyInfo?.endTime) { + throw new ForbiddenAccessException('Study group session is not available') + } + return { userId: membership.userId, userName: membership.user.username, isLeader: membership.isGroupLeader, - endTime: membership.group.studyInfo!.endTime + endTime: membership.group.studyInfo.endTime } } } From 9502c4525704dfc7837bad1c7428a5f29fe943b8 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:45:47 +0000 Subject: [PATCH 65/72] refactor(be): remove unused study room member fields --- .../apps/client/src/study/interface/study-socket.interface.ts | 1 - apps/backend/apps/client/src/study/study-room.service.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 041a94ff46..08388efc59 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -24,7 +24,6 @@ export interface RoomState { export interface RoomMember { userId: number - socketId: string userName: string isLeader: boolean joinedAt: number diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index ab6a826213..a7096c1b1b 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -229,11 +229,11 @@ export class StudyRoomService { isLeader: membership.isLeader, groupId } + client.join(roomKey(groupId)) await this.addMember(groupId, userId, { userId, - socketId: client.id, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now @@ -413,7 +413,7 @@ export class StudyRoomService { await this.queue.add( JOB_RECONNECT_EXPIRE, - { userId, groupId, userName }, + { userId, groupId }, { jobId, delay: RECONNECT_GRACE_SEC * 1000, From 964976c89a7b71f3865d314c737f6aeda9264536 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 14:48:46 +0000 Subject: [PATCH 66/72] docs(be): update JSDoc for study room leave --- apps/backend/apps/client/src/study/study-room.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index a7096c1b1b..6c976639c4 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -355,7 +355,7 @@ export class StudyRoomService { /** * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * - * 1. client.data에서 groupId를 초기화 + * 1. client.data.room을 초기화하여 룸 데이터 제거 * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 3. Socket.io 룸에서 클라이언트를 제외(leave) * 4. 호스트 퇴장 시 새 호스트 지정 From a3490995083b64daa47da6376377c141753318e9 Mon Sep 17 00:00:00 2001 From: westsunh Date: Wed, 13 May 2026 16:12:39 +0000 Subject: [PATCH 67/72] fix(be): add Redis password to BullMQ --- apps/backend/apps/client/src/app.module.ts | 7 ++++--- apps/backend/libs/redis/src/redis.module.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index 2cf13452e0..259ff8b3e4 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -61,10 +61,11 @@ import { WorkbookModule } from './workbook/workbook.module' RedisModule, BullModule.forRootAsync({ inject: [ConfigService], - useFactory: (config: ConfigService) => ({ + useFactory: (configService: ConfigService) => ({ connection: { - host: config.get('REDIS_HOST'), - port: config.get('REDIS_PORT'), + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), db: 1 }, prefix: 'bull-client' diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts index 0fc8d3ce11..b7e3a5fe23 100644 --- a/apps/backend/libs/redis/src/redis.module.ts +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -10,16 +10,17 @@ export const REDIS_CLIENT = 'REDIS_CLIENT' providers: [ { provide: REDIS_CLIENT, - useFactory: async (config: ConfigService) => { - const host = config.get('REDIS_HOST') - const port = config.get('REDIS_PORT') + useFactory: async (configService: ConfigService) => { + const host = configService.get('REDIS_HOST') + const port = configService.get('REDIS_PORT') + const password = configService.get('REDIS_PASSWORD') const db = 1 if (!host || !port) { throw new Error('Redis host and port must be configured') } - const redis = new Redis({ host, port, db }) + const redis = new Redis({ host, port, password, db }) try { const result = await redis.ping() From 5b1c45127be91d8c87e629652dd78beac83b9317 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 14 May 2026 08:46:36 +0000 Subject: [PATCH 68/72] refactor(be): prevent race condition in enterRoom --- .../client/src/study/study-room.service.ts | 79 +++++++++++-------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 6c976639c4..3296692b56 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -46,6 +46,8 @@ export class StudyRoomService { * 2. 사용자의 스터디 그룹 참여 권한 검증 * 3. Redis에서 룸 상태 생성 또는 기존 상태 조회하고 유효성 검증 * 4. Socket.IO 룸 입장 및 Redis 멤버 정보 등록 + * - 신규: HSETNX로 중복 입장 차단 + * - 재연결: reconnectKey DEL 성공 여부로 중복 재연결 차단 * 5. 입장 케이스에 따라 이벤트 emit * - 첫 번째 입장이면 room:started 이벤트 브로드캐스트 * - 재연결 입장이면 room:participantReconnected 이벤트 emit @@ -97,7 +99,16 @@ export class StudyRoomService { return { success: false, message: '룸 상태 가져오지 못했습니다.' } const isRecovered = check.isRecovered ?? false - await this.enterRoom(client, groupId, userId, membership, now, isRecovered) + const entered = await this.enterRoom( + client, + groupId, + userId, + membership, + now, + isRecovered + ) + if (!entered) + return { success: false, message: '이미 이 룸에 참여 중입니다.' } if (isFirst) await this.redis.expireat( @@ -194,9 +205,13 @@ export class StudyRoomService { /** * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. * - * 1. 재연결 입장인 경우 reconnectKey 삭제를 시도하고, 예약되어 있던 재연결 만료 작업 취소 + * 1. 재연결 입장인 경우 reconnectKey를 원자적으로 삭제 + * - 삭제 실패(0)이면 다른 요청이 이미 재연결 처리 중으로 보고 false 반환 + * - 삭제 성공(1)이면 예약된 재연결 만료 작업 취소 * 2. socket.data에 사용자 메타데이터를 저장 - * 3. Socket.IO 룸에 참여 + * 3. 신규 입장인 경우 HSETNX로 원자적으로 멤버 등록 + * - 이미 존재하면(0) 중복 입장으로 판단 → false 반환 + * - 재연결 입장인 경우 HSET으로 멤버 정보 갱신 * 4. Redis 멤버 목록에 현재 소켓 정보 등록 * * @param {Socket} client 연결된 Socket 인스턴스 @@ -205,6 +220,7 @@ export class StudyRoomService { * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 * @param {number} now 입장 시각(ms) * @param {boolean} isRecovered 재연결 복구 여부 + * @returns 입장 성공 여부 (false면 중복 입장 또는 재연결 경쟁 패배) */ private async enterRoom( client: Socket, @@ -213,15 +229,15 @@ export class StudyRoomService { membership: { userName: string; isLeader: boolean }, now: number, isRecovered: boolean - ): Promise { + ): Promise { if (isRecovered) { const deleted = await this.redis.del(reconnectKey(groupId, userId)) - if (deleted) { - const job = await this.queue.getJob( - `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` - ) - await job?.remove() - } + if (!deleted) return false + + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() } client.data.room = { @@ -230,14 +246,30 @@ export class StudyRoomService { groupId } - client.join(roomKey(groupId)) - - await this.addMember(groupId, userId, { + const member: RoomMember = { userId, userName: membership.userName, isLeader: membership.isLeader, joinedAt: now - }) + } + + if (isRecovered) { + await this.redis.hset( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) + } else { + const added = await this.redis.hsetnx( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) + if (!added) return false + } + + client.join(roomKey(groupId)) + return true } /** @@ -556,25 +588,6 @@ export class StudyRoomService { return raw ? (JSON.parse(raw) as RoomState) : null } - /** - * Redis Hash에 멤버 정보를 사용자 ID를 키로 하여 추가합니다. - * - * @param {number} groupId 스터디 그룹 ID - * @param {number} userId 사용자 ID - * @param {RoomMember} member 저장할 멤버 정보 - */ - private async addMember( - groupId: number, - userId: number, - member: RoomMember - ): Promise { - await this.redis.hset( - membersKey(groupId), - String(userId), - JSON.stringify(member) - ) - } - /** * Redis Hash에서 특정 사용자의 멤버 정보를 삭제합니다. * From 0fac57f17d1a546bd3295a4d0996484457e870e0 Mon Sep 17 00:00:00 2001 From: westsunh Date: Thu, 14 May 2026 12:31:13 +0000 Subject: [PATCH 69/72] refactor(be): improve room cleanup and edge case handling in StudyRoomService --- .../client/src/study/study-room.service.ts | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 3296692b56..11b02b161b 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -388,10 +388,11 @@ export class StudyRoomService { * 사용자가 스터디 룸을 정상적으로 퇴장합니다. * * 1. client.data.room을 초기화하여 룸 데이터 제거 - * 2. Redis Hash에서 해당 사용자의 멤버 정보 삭제 - * 3. Socket.io 룸에서 클라이언트를 제외(leave) - * 4. 호스트 퇴장 시 새 호스트 지정 - * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 2. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 3. Redis Hash에서 해당 사용자의 멤버 정보 삭제 + * 4. Socket.io 룸에서 클라이언트를 제외(leave) + * 5. 호스트 퇴장 시 새 호스트 지정 + * 6. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @returns 퇴장 성공 여부 @@ -402,6 +403,9 @@ export class StudyRoomService { client.data.room = undefined + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return { success: true } + await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) @@ -417,9 +421,10 @@ export class StudyRoomService { /** * 웹소켓 연결이 끊어졌을 때 호출되며, 재연결 유예 기간(Grace Period)을 부여합니다. * - * 1. Redis에 재연결 대기 상태 키 저장 - * 2. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 - * 3. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 + * 1. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 2. Redis에 재연결 대기 상태 키 저장 + * 3. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 + * 4. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 * * @param {Socket} client 연결된 Socket 인스턴스 */ @@ -429,6 +434,9 @@ export class StudyRoomService { const groupId = client.data.room?.groupId if (!userId || !groupId) return + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return + const userName = client.data.room.userName client.data.room = undefined @@ -481,8 +489,10 @@ export class StudyRoomService { * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. * * 1. 룸 전체에 room:ended 이벤트를 전송 - * 2. 연결 중인 유저 Key 삭제 후 룸에 남아 있는 소켓을 Socket.IO 룸에서 강제 퇴장 - * 3. Redis의 룸 상태와 멤버 목록을 삭제하여 다음 입장 시 새 세션을 시작할 수 있게 처리 + * 2. 재연결 대기 중인 모든 멤버의 reconnectKey를 Redis에서 삭제 + * 3. Redis의 룸 상태(roomKey)와 멤버 목록(membersKey)을 삭제 + * 4. 룸에 남아 있는 모든 소켓의 WebSocket 연결을 끊음 (disconnectSockets) + * — 멀티 노드 환경에서도 각 노드가 자신의 소켓을 직접 disconnect 처리함 * * @param {number} groupId 스터디 그룹 ID */ @@ -494,18 +504,12 @@ export class StudyRoomService { members.map((m) => this.redis.del(reconnectKey(groupId, m.userId))) ) - const sockets = await this.server.in(roomKey(groupId)).fetchSockets() - await Promise.all( - sockets.map((s) => { - s.data.room = undefined - s.leave(roomKey(groupId)) - }) - ) - await Promise.all([ this.redis.del(roomKey(groupId)), this.redis.del(membersKey(groupId)) ]) + + await this.server.in(roomKey(groupId)).disconnectSockets() } /** @@ -606,7 +610,6 @@ export class StudyRoomService { */ async getMembers(groupId: number): Promise { const raw = await this.redis.hgetall(membersKey(groupId)) - if (!raw) return [] return Object.values(raw).flatMap((v) => { try { return [JSON.parse(v) as RoomMember] From 246f926e05bb766809d1711d84827cb8f6c4631a Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 24 May 2026 12:31:55 +0000 Subject: [PATCH 70/72] feat(be): restrict WebSocket CORS origin by encironment --- apps/backend/apps/client/src/study/study.gateway.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts index 3b47d76d81..2d67dad190 100644 --- a/apps/backend/apps/client/src/study/study.gateway.ts +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -18,9 +18,13 @@ import { StudyRoomService } from './study-room.service' @WebSocketGateway({ namespace: 'study', cors: { - // TODO 실제 서버 환경에 맞는 'cors' RedisIoAdapter 내부에 설정 - // origin: ['ws://localhost:3002/api/room'] - origin: true, + origin: + process.env.APP_ENV === 'production' || process.env.APP_ENV === 'stage' + ? true + : [ + /^https:\/\/\d+\.preview\.codedang\.com$/, // preview + /^http:\/\/localhost(:\d+)?$/ // local + ], credentials: true } }) From cfc9a4c33074d187c91a666893156edec72a4c21 Mon Sep 17 00:00:00 2001 From: westsunh Date: Sun, 24 May 2026 13:30:30 +0000 Subject: [PATCH 71/72] refactor(be): move study-room constants to libs --- .../study/interface/study-socket.interface.ts | 18 --------------- .../client/src/study/study-room.processor.ts | 4 ++-- .../client/src/study/study-room.service.ts | 23 ++++++++++--------- apps/backend/libs/constants/src/index.ts | 1 + .../constants/src/study-room.constants.ts | 20 ++++++++++++++++ 5 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 apps/backend/libs/constants/src/study-room.constants.ts diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 08388efc59..872b739e53 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,21 +1,3 @@ -// Redis Key -export const STUDY_KEY_PREFIX = 'study' -export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` -export const STUDY_CHAT_PREFIX = `${STUDY_KEY_PREFIX}:chat` - -export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` -export const membersKey = (groupId: number) => - `${STUDY_ROOM_PREFIX}:${groupId}:members` -export const reconnectKey = (groupId: number, userId: number) => - `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` - -export const chatKey = (groupId: number) => `${STUDY_CHAT_PREFIX}:${groupId}` - -// 상수 -export const RECONNECT_GRACE_SEC = 30 // 30초 -export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 -export const BUFFER_SEC = 10 // 10초 - // Room export interface RoomState { endAt: number diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts index 56dddcfaab..baf14ee61b 100644 --- a/apps/backend/apps/client/src/study/study-room.processor.ts +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -2,12 +2,12 @@ import { Processor, WorkerHost } from '@nestjs/bullmq' import { Logger } from '@nestjs/common' import { Job } from 'bullmq' import { - StudyRoomService, STUDY_ROOM_QUEUE, JOB_RECONNECT_EXPIRE, JOB_ROOM_REMINDER, JOB_ROOM_END -} from './study-room.service' +} from '@libs/constants' +import { StudyRoomService } from './study-room.service' @Processor(STUDY_ROOM_QUEUE) export class StudyRoomProcessor extends WorkerHost { diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index 11b02b161b..d49f1830ee 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -3,26 +3,27 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import type { Queue } from 'bullmq' import type Redis from 'ioredis' import type { Server, Socket } from 'socket.io' -import { REDIS_CLIENT } from '@libs/redis' import { roomKey, reconnectKey, membersKey, + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END, RECONNECT_GRACE_SEC, REMINDER_BEFORE_END_MS, - BUFFER_SEC, - type RoomState, - type RoomMember, - type JoinResponse, - type SocketResponse + BUFFER_SEC +} from '@libs/constants' +import { REDIS_CLIENT } from '@libs/redis' +import type { + RoomState, + RoomMember, + JoinResponse, + SocketResponse } from './interface/study-socket.interface' import { StudyService } from './study.service' -export const STUDY_ROOM_QUEUE = 'study-room' -export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' -export const JOB_ROOM_REMINDER = 'room-reminder' -export const JOB_ROOM_END = 'room-end' - @Injectable() export class StudyRoomService { private readonly logger = new Logger(StudyRoomService.name) diff --git a/apps/backend/libs/constants/src/index.ts b/apps/backend/libs/constants/src/index.ts index 825dbb8be4..a2c24164ce 100644 --- a/apps/backend/libs/constants/src/index.ts +++ b/apps/backend/libs/constants/src/index.ts @@ -5,3 +5,4 @@ export * from './check.constants' export * from './submission.constants' export * from './argon2.constants' export * from './score-calculation.constants' +export * from './study-room.constants' diff --git a/apps/backend/libs/constants/src/study-room.constants.ts b/apps/backend/libs/constants/src/study-room.constants.ts new file mode 100644 index 0000000000..6d4b605cb9 --- /dev/null +++ b/apps/backend/libs/constants/src/study-room.constants.ts @@ -0,0 +1,20 @@ +// Redis Key +export const STUDY_KEY_PREFIX = 'study' +export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` + +export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` +export const membersKey = (groupId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:members` +export const reconnectKey = (groupId: number, userId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` + +// BullMQ Queue Job Names +export const STUDY_ROOM_QUEUE = 'study-room' +export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' +export const JOB_ROOM_REMINDER = 'room-reminder' +export const JOB_ROOM_END = 'room-end' + +// 상수 +export const RECONNECT_GRACE_SEC = 30 // 30초 +export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 +export const BUFFER_SEC = 10 // 10초 From 14e614114be10fe3f25d4895483ab21de82a5043 Mon Sep 17 00:00:00 2001 From: westsunh Date: Tue, 26 May 2026 12:20:10 +0000 Subject: [PATCH 72/72] refactor(be): remove host and simplify room state --- .../study/interface/study-socket.interface.ts | 5 +- .../client/src/study/study-room.service.ts | 86 ++++++++----------- 2 files changed, 36 insertions(+), 55 deletions(-) diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts index 872b739e53..0d8b7c631e 100644 --- a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -1,13 +1,12 @@ // Room export interface RoomState { endAt: number - hostUserId: number + leaderId: number | null } export interface RoomMember { userId: number userName: string - isLeader: boolean joinedAt: number } @@ -23,7 +22,7 @@ export interface JoinResponse { members: RoomMember[] endAt: number remainMs: number - hostUserId: number + leaderId: number | null } // PayLoad diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts index d49f1830ee..df1afb41a7 100644 --- a/apps/backend/apps/client/src/study/study-room.service.ts +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -92,6 +92,7 @@ export class StudyRoomService { const { state, isFirst } = await this.initOrGetRoom( groupId, userId, + membership.isLeader, now, membership.endTime ) @@ -104,7 +105,7 @@ export class StudyRoomService { client, groupId, userId, - membership, + membership.userName, now, isRecovered ) @@ -129,7 +130,7 @@ export class StudyRoomService { members, endAt: state.endAt, remainMs: Math.max(0, state.endAt - Date.now()), - hostUserId: state.hostUserId + leaderId: state.leaderId } } } @@ -174,6 +175,7 @@ export class StudyRoomService { * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 * - 성공이면, 첫 번째 입장자로 -> 생성한 activeState 사용 * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 + * - 리더 입장이고 leaderId가 미설정 상태이면 KEEPTTL로 업데이트 * * @param {number} groupId 스터디 그룹 ID * @param {number} userId 현재 사용자 ID @@ -183,12 +185,13 @@ export class StudyRoomService { private async initOrGetRoom( groupId: number, userId: number, + isLeader: boolean, now: number, endTime: Date ): Promise<{ state: RoomState | null; isFirst: boolean }> { const activeState: RoomState = { endAt: endTime.getTime(), - hostUserId: userId + leaderId: isLeader ? userId : null } const isFirst = !!(await this.redis.set( @@ -199,7 +202,16 @@ export class StudyRoomService { 'NX' )) - const state = isFirst ? activeState : await this.getRoomState(groupId) + if (isFirst) return { state: activeState, isFirst } + + const state = await this.getRoomState(groupId) + + if (isLeader && state && state.leaderId === null) { + const updated = { ...state, leaderId: userId } + await this.redis.set(roomKey(groupId), JSON.stringify(updated), 'KEEPTTL') + return { state: updated, isFirst } + } + return { state, isFirst } } @@ -218,7 +230,7 @@ export class StudyRoomService { * @param {Socket} client 연결된 Socket 인스턴스 * @param {number} groupId 스터디 그룹 ID * @param {number} userId 사용자 ID - * @param { userName: string; isLeader: boolean } membership 사용자 이름과 리더 여부 + * @param {string} userName 사용자 이름과 리더 여부 * @param {number} now 입장 시각(ms) * @param {boolean} isRecovered 재연결 복구 여부 * @returns 입장 성공 여부 (false면 중복 입장 또는 재연결 경쟁 패배) @@ -227,7 +239,7 @@ export class StudyRoomService { client: Socket, groupId: number, userId: number, - membership: { userName: string; isLeader: boolean }, + userName: string, now: number, isRecovered: boolean ): Promise { @@ -242,23 +254,30 @@ export class StudyRoomService { } client.data.room = { - userName: membership.userName, - isLeader: membership.isLeader, + userName, groupId } const member: RoomMember = { userId, - userName: membership.userName, - isLeader: membership.isLeader, + userName, joinedAt: now } if (isRecovered) { + const existing = await this.redis.hget( + membersKey(groupId), + String(userId) + ) + + if (!existing) return false + + const { joinedAt: originalJoinedAt } = JSON.parse(existing) as RoomMember + await this.redis.hset( membersKey(groupId), String(userId), - JSON.stringify(member) + JSON.stringify({ ...member, joinedAt: originalJoinedAt }) ) } else { const added = await this.redis.hsetnx( @@ -392,8 +411,7 @@ export class StudyRoomService { * 2. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) * 3. Redis Hash에서 해당 사용자의 멤버 정보 삭제 * 4. Socket.io 룸에서 클라이언트를 제외(leave) - * 5. 호스트 퇴장 시 새 호스트 지정 - * 6. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 * * @param {Socket} client 연결된 Socket 인스턴스 * @returns 퇴장 성공 여부 @@ -410,7 +428,7 @@ export class StudyRoomService { await this.removeMember(groupId, userId) client.leave(roomKey(groupId)) - const members = await this.transferHostIfNeeded(groupId, userId) + const members = await this.getMembers(groupId) this.server .to(roomKey(groupId)) @@ -521,8 +539,7 @@ export class StudyRoomService { * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 * 2. Redis 멤버 목록에서 해당 사용자 삭제 - * 3. 호스트 퇴장 시 새 호스트 지정 - * 4. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 * * @param payload * @param {number} payload.userId 사용자 ID @@ -539,48 +556,13 @@ export class StudyRoomService { await this.removeMember(groupId, userId) - const members = await this.transferHostIfNeeded(groupId, userId) + const members = await this.getMembers(groupId) this.server .to(roomKey(groupId)) .emit('room:participantsChanged', { members }) } - /** - * 호스트가 퇴장했을 때 새 호스트를 지정합니다. - * 우선순위: isLeader인 멤버 → 없으면 joinedAt 기준 가장 먼저 입장한 멤버 - * - * @param {number} groupId 스터디 그룹 ID - * @param {number} leavingUserId 퇴장한 호스트 userId - * @returns 스터디 룸에 남아있는 사용자들 - */ - private async transferHostIfNeeded( - groupId: number, - leavingUserId: number - ): Promise { - const state = await this.getRoomState(groupId) - const members = await this.getMembers(groupId) - - if (state && state.hostUserId === leavingUserId && members.length > 0) { - const newHost = - members.find((m) => m.isLeader) ?? - [...members].sort((a, b) => a.joinedAt - b.joinedAt)[0] - - await this.redis.set( - roomKey(groupId), - JSON.stringify({ ...state, hostUserId: newHost.userId }), - 'KEEPTTL' - ) - - this.server.to(roomKey(groupId)).emit('room:hostChanged', { - hostUserId: newHost.userId, - hostUserName: newHost.userName - }) - } - - return members - } - // Redis /** * Redis에서 룸 상태를 조회합니다.