diff --git a/.github/workflows/server-test.yml b/.github/workflows/server-test.yml index e6ed660..e49e83e 100644 --- a/.github/workflows/server-test.yml +++ b/.github/workflows/server-test.yml @@ -54,12 +54,24 @@ jobs: --health-timeout 5s --health-retries 5 + redis: + image: redis:7-alpine + ports: + - "6379:6379" + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: DATABASE_URL: postgresql://test:test@localhost:5432/dookmark_test JWT_SECRET: test-jwt-secret GOOGLE_CLIENT_ID: dummy-client-id GOOGLE_CLIENT_SECRET: dummy-client-secret GOOGLE_CALLBACK_URL: http://localhost:3001/auth/google/callback + REDIS_HOST: localhost + REDIS_PORT: 6379 NODE_ENV: test steps: diff --git a/apps/server/.env.example b/apps/server/.env.example index 3a6d37d..de54ae6 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -24,3 +24,9 @@ FRONTEND_URL="https://YOUR_FRONTEND_DOMAIN" # Runtime environment NODE_ENV=production + +# Redis configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_TLS=false diff --git a/apps/server/package.json b/apps/server/package.json index 972ff64..75fda27 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -12,7 +12,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "test": "jest", + "test": "jest --forceExit", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", @@ -40,11 +40,13 @@ "@nestjs/websockets": "^11.1.27", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.6.0", + "@socket.io/redis-adapter": "^8.3.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", "helmet": "^8.2.0", + "ioredis": "^5.11.1", "nodemailer": "^9.0.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", @@ -63,6 +65,7 @@ "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", + "@types/ioredis-mock": "^8.2.7", "@types/jest": "^30.0.0", "@types/node": "^22.19.15", "@types/nodemailer": "^8.0.1", @@ -71,6 +74,7 @@ "@types/pg": "^8.20.0", "@types/supertest": "^6.0.2", "globals": "^16.0.0", + "ioredis-mock": "^8.13.1", "jest": "^30.0.0", "prisma": "^7.6.0", "source-map-support": "^0.5.21", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index bcc0edc..ca0ef27 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -9,6 +9,7 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { BookmarkModule } from './bookmark/bookmark.module'; import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter'; +import { RedisModule } from './common/redis/redis.module'; import { validateEnv } from './config/env.validation'; import { PrismaModule } from './prisma/prisma.module'; import { SessionModule } from './session/session.module'; @@ -18,6 +19,7 @@ import { UserModule } from './user/user.module'; imports: [ ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), PrismaModule, + RedisModule, UserModule, SessionModule, AuthModule, diff --git a/apps/server/src/bookmark/gateways/bookmark.gateway.spec.ts b/apps/server/src/bookmark/gateways/bookmark.gateway.spec.ts index 306f6f2..0b20247 100644 --- a/apps/server/src/bookmark/gateways/bookmark.gateway.spec.ts +++ b/apps/server/src/bookmark/gateways/bookmark.gateway.spec.ts @@ -2,26 +2,39 @@ import { Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, type TestingModule } from '@nestjs/testing'; import { BrowserType } from '@prisma/client'; -import type { Socket } from 'socket.io'; +import type { Server, Socket } from 'socket.io'; import type { PartialServiceMock, ServiceMock } from '../../../test/mock.types'; +import { RedisService } from '../../common/redis/redis.service'; import { BookmarkGateway } from './bookmark.gateway'; const userId = 'user-id'; -// 테스트용 가짜 Socket (handshake/ disconnect/ emit만 사용) +// 테스트용 가짜 Socket (handshake/ disconnect/ emit/ join/ to 사용) const makeSocket = (id: string, opts: { token?: string; browser?: string } = {}): Socket => { const { token = 'valid-token', browser = BrowserType.CHROME } = opts; + const mockEmit = jest.fn(); + const mockDisconnectSockets = jest.fn(); + const mockTo = jest.fn().mockReturnValue({ + disconnectSockets: mockDisconnectSockets, + }); + return { id, handshake: { auth: { token }, query: { browser } }, disconnect: jest.fn(), - emit: jest.fn(), + emit: mockEmit, + join: jest.fn(), + to: mockTo, } as unknown as Socket; }; describe('BookmarkGateway', () => { let gateway: BookmarkGateway; let jwtMock: ServiceMock; + let redisMock: ServiceMock; + let mockEmit: jest.Mock; + let mockExcept: jest.Mock; + let mockTo: jest.Mock; beforeEach(async () => { // 노이즈 억제: 연결/해제 시 다량의 로그가 출력된다 @@ -33,12 +46,36 @@ describe('BookmarkGateway', () => { verify: jest.fn().mockReturnValue({ sub: userId }), }; + const mockRedis: PartialServiceMock = { + zadd: jest.fn(), + zrem: jest.fn(), + zremrangebyscore: jest.fn(), + zrange: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [BookmarkGateway, { provide: JwtService, useValue: mockJwt }], + providers: [ + BookmarkGateway, + { provide: JwtService, useValue: mockJwt }, + { provide: RedisService, useValue: mockRedis }, + ], }).compile(); gateway = module.get(BookmarkGateway); jwtMock = module.get>(JwtService); + redisMock = module.get>(RedisService); + + // Socket.io Server 모킹 설정 + mockEmit = jest.fn(); + mockExcept = jest.fn().mockReturnValue({ emit: mockEmit }); + mockTo = jest.fn().mockReturnValue({ + except: mockExcept, + emit: mockEmit, + }); + + gateway.server = { + to: mockTo, + } as unknown as Server; }); afterEach(() => { @@ -82,91 +119,80 @@ describe('BookmarkGateway', () => { expect(socket.disconnect).toHaveBeenCalled(); }); - it('정상 연결은 끊기지 않고 등록되어 알림 대상이 된다', async () => { + it('정상 연결은 끊기지 않고 룸 가입 및 ZSET 등록이 진행된다', async () => { const socket = makeSocket('s1', { browser: BrowserType.FIREFOX }); await gateway.handleConnection(socket); expect(socket.disconnect).not.toHaveBeenCalled(); - // 다른 브라우저(CHROME) 소스 업데이트 → FIREFOX 연결로 알림이 가야 한다 - gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME); - expect(socket.emit).toHaveBeenCalledWith('bookmark_updated', { browser: BrowserType.CHROME }); + expect(socket.join).toHaveBeenCalledWith(`user:${userId}`); + expect(socket.join).toHaveBeenCalledWith(`user:${userId}:${BrowserType.FIREFOX}`); + expect(redisMock.zadd).toHaveBeenCalledWith( + `user:${userId}:active_browsers`, + expect.any(Number), + BrowserType.FIREFOX, + ); }); - it('같은 브라우저의 기존 연결이 있으면 끊고 새 연결로 교체한다', async () => { - const oldSocket = makeSocket('old', { browser: BrowserType.CHROME }); - const newSocket = makeSocket('new', { browser: BrowserType.CHROME }); + it('같은 브라우저의 기존 연결이 있으면 disconnectSockets를 호출한다', async () => { + const socket = makeSocket('new', { browser: BrowserType.CHROME }); - await gateway.handleConnection(oldSocket); - await gateway.handleConnection(newSocket); + await gateway.handleConnection(socket); - expect(oldSocket.disconnect).toHaveBeenCalled(); + expect(socket.to).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`); + const mockToResult = socket.to(`user:${userId}:${BrowserType.CHROME}`); + expect(mockToResult.disconnectSockets).toHaveBeenCalledWith(true); }); }); describe('handleDisconnect', () => { - it('연결을 제거하여 더 이상 알림을 받지 않는다', async () => { + it('연결 해제 시 ZSET에서 제거하고 타이머를 해제한다', async () => { const socket = makeSocket('s1', { browser: BrowserType.FIREFOX }); await gateway.handleConnection(socket); - gateway.handleDisconnect(socket); + await gateway.handleDisconnect(socket); - gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME); - expect(socket.emit).not.toHaveBeenCalled(); + expect(redisMock.zrem).toHaveBeenCalledWith( + `user:${userId}:active_browsers`, + BrowserType.FIREFOX, + ); }); }); describe('notifyBookmarkUpdate', () => { - it('소스 브라우저 자신에게는 알림을 보내지 않는다', async () => { - const chrome = makeSocket('c', { browser: BrowserType.CHROME }); - await gateway.handleConnection(chrome); - + it('소스 브라우저를 제외하고 해당 유저 룸에 이벤트를 보낸다', () => { gateway.notifyBookmarkUpdate(userId, BrowserType.CHROME); - expect(chrome.emit).not.toHaveBeenCalled(); - }); - - it('연결된 사용자가 없으면 아무 일도 하지 않는다', () => { - expect(() => gateway.notifyBookmarkUpdate('ghost-user', BrowserType.CHROME)).not.toThrow(); + expect(mockTo).toHaveBeenCalledWith(`user:${userId}`); + expect(mockExcept).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`); + expect(mockEmit).toHaveBeenCalledWith('bookmark_updated', { browser: BrowserType.CHROME }); }); }); describe('notifyTrashEmptied', () => { - it('해당 사용자의 모든 연결에 휴지통 비움을 알린다', async () => { - const chrome = makeSocket('c', { browser: BrowserType.CHROME }); - const firefox = makeSocket('f', { browser: BrowserType.FIREFOX }); - await gateway.handleConnection(chrome); - await gateway.handleConnection(firefox); - + it('해당 사용자 룸 전체에 휴지통 비움을 알린다', () => { gateway.notifyTrashEmptied(userId); - expect(chrome.emit).toHaveBeenCalledWith('bookmark_trash_emptied'); - expect(firefox.emit).toHaveBeenCalledWith('bookmark_trash_emptied'); + expect(mockTo).toHaveBeenCalledWith(`user:${userId}`); + expect(mockEmit).toHaveBeenCalledWith('bookmark_trash_emptied'); }); }); describe('sendCrossBrowserMove / sendNativeAction', () => { - it('지정한 대상 브라우저 연결에만 이동 명령을 보낸다', async () => { - const chrome = makeSocket('c', { browser: BrowserType.CHROME }); - const firefox = makeSocket('f', { browser: BrowserType.FIREFOX }); - await gateway.handleConnection(chrome); - await gateway.handleConnection(firefox); - + it('지정한 대상 브라우저 전용 룸에 이동 명령을 보낸다', () => { const payload = { action: 'remove' as const, id: 'src' }; gateway.sendCrossBrowserMove(userId, BrowserType.FIREFOX, payload); - expect(firefox.emit).toHaveBeenCalledWith('bookmark_cross_moved', payload); - expect(chrome.emit).not.toHaveBeenCalled(); + expect(mockTo).toHaveBeenCalledWith(`user:${userId}:${BrowserType.FIREFOX}`); + expect(mockEmit).toHaveBeenCalledWith('bookmark_cross_moved', payload); }); - it('지정한 대상 브라우저 연결에만 네이티브 액션을 보낸다', async () => { - const chrome = makeSocket('c', { browser: BrowserType.CHROME }); - await gateway.handleConnection(chrome); - + it('지정한 대상 브라우저 전용 룸에 네이티브 액션을 보낸다', () => { const payload = { action: 'delete' as const, browserBookmarkId: 'node-id' }; gateway.sendNativeAction(userId, BrowserType.CHROME, payload); - expect(chrome.emit).toHaveBeenCalledWith('bookmark_native_action', payload); + expect(mockTo).toHaveBeenCalledWith(`user:${userId}:${BrowserType.CHROME}`); + expect(mockEmit).toHaveBeenCalledWith('bookmark_native_action', payload); }); }); }); diff --git a/apps/server/src/bookmark/gateways/bookmark.gateway.ts b/apps/server/src/bookmark/gateways/bookmark.gateway.ts index bb6999f..6257a4c 100644 --- a/apps/server/src/bookmark/gateways/bookmark.gateway.ts +++ b/apps/server/src/bookmark/gateways/bookmark.gateway.ts @@ -12,6 +12,11 @@ import { } from '@nestjs/websockets'; import { BrowserType } from '@prisma/client'; import { Server, Socket } from 'socket.io'; +import { RedisService } from '../../common/redis/redis.service'; + +interface CustomSocket extends Socket { + heartbeatInterval?: NodeJS.Timeout; +} const wsAllowedOrigins = new Set( [process.env.FRONTEND_URL, 'http://localhost:3000', 'http://127.0.0.1:3000'].filter( @@ -41,15 +46,12 @@ export class BookmarkGateway implements OnGatewayConnection, OnGatewayDisconnect private readonly logger = new Logger(BookmarkGateway.name); @WebSocketServer() - server: Server; - - // userId -> Array of { socket, browser } - private readonly activeConnections = new Map< - string, - { socket: Socket; browser: BrowserType }[] - >(); + server!: Server; - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private readonly redisService: RedisService, + ) {} async handleConnection(socket: Socket) { try { @@ -77,21 +79,31 @@ export class BookmarkGateway implements OnGatewayConnection, OnGatewayDisconnect return; } - let connections = this.activeConnections.get(userId); - if (!connections) { - connections = []; - this.activeConnections.set(userId, connections); - } + // 1. 해당 유저의 룸에 가입 처리 + await socket.join(`user:${userId}`); + // 2. 해당 유저의 특정 브라우저 전용 룸에 가입 처리 + await socket.join(`user:${userId}:${browser}`); + + // 3. 동일 브라우저의 기존 소켓 연결 강제 종료 처리 (다중 서버 인스턴스 전반에 적용) + this.logger.log(`Replacing old connections for User ${userId} on ${browser}`); + socket.to(`user:${userId}:${browser}`).disconnectSockets(true); + + // 4. Redis ZSET에 활성 커넥션 정보 기록 (만료 방지 score = 현재 타임스탬프) + const now = Date.now(); + await this.redisService.zadd(`user:${userId}:active_browsers`, now, browser); + + // 5. 주기적으로 ZSET score를 갱신하기 위한 하트비트 타이머 등록 + const heartbeatInterval = setInterval(async () => { + try { + await this.redisService.zadd(`user:${userId}:active_browsers`, Date.now(), browser); + } catch (err) { + this.logger.error(`Failed to update heartbeat for User ${userId} on ${browser}:`, err); + } + }, 30000); - // 동일한 브라우저의 기존 소켓이 살아있다면 끊고 새 것으로 대체 - const existingIndex = connections.findIndex((conn) => conn.browser === browser); - if (existingIndex !== -1) { - const [oldConn] = connections.splice(existingIndex, 1); - this.logger.log(`Replacing old connection for User ${userId} on ${browser}`); - oldConn.socket.disconnect(); - } + // 소켓 객체에 타이머 ID를 바인딩하여 disconnect 시점에 해제 + (socket as CustomSocket).heartbeatInterval = heartbeatInterval; - connections.push({ socket, browser }); this.logger.log(`User ${userId} connected via ${browser} (Socket ID: ${socket.id})`); } catch (err) { this.logger.error(`Connection verification failed for socket ${socket.id}:`, err); @@ -99,78 +111,72 @@ export class BookmarkGateway implements OnGatewayConnection, OnGatewayDisconnect } } - handleDisconnect(socket: Socket) { - for (const [userId, connections] of this.activeConnections.entries()) { - const index = connections.findIndex((conn) => conn.socket.id === socket.id); - if (index !== -1) { - const [removed] = connections.splice(index, 1); - this.logger.log( - `User ${userId} disconnected from ${removed.browser} (Socket: ${socket.id})`, - ); - - if (connections.length === 0) { - this.activeConnections.delete(userId); + async handleDisconnect(socket: Socket) { + try { + const token = socket.handshake.auth?.token || socket.handshake.query?.token; + const browser = socket.handshake.query?.browser as BrowserType; + + if (token && browser) { + const payload = this.jwtService.verify(token); + const userId = payload.sub; + if (userId) { + // Redis ZSET에서 활성 커넥션 목록 제거 + await this.redisService.zrem(`user:${userId}:active_browsers`, browser); + this.logger.log(`User ${userId} disconnected from ${browser} (Socket: ${socket.id})`); } - break; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Failed to clean up connection in Redis on disconnect: ${message}`); + } finally { + const interval = (socket as CustomSocket).heartbeatInterval; + if (interval) { + clearInterval(interval); } } } + /** + * 특정 사용자의 실시간 활성 브라우저 목록을 조회합니다. (오래된 좀비 커넥션 자동 제거) + */ + async getActiveBrowsers(userId: string): Promise { + const key = `user:${userId}:active_browsers`; + const threshold = Date.now() - 60000; // 60초 기준 만료 + await this.redisService.zremrangebyscore(key, '-inf', threshold); + const members = await this.redisService.zrange(key, 0, -1); + return members as BrowserType[]; + } + // 특정 사용자에게 업데이트 알림 전송 (나를 제외한 다른 켜져 있는 브라우저들로 전송) notifyBookmarkUpdate(userId: string, sourceBrowser: BrowserType) { - const connections = this.activeConnections.get(userId); - if (!connections) return; - - for (const conn of connections) { - if (conn.browser !== sourceBrowser) { - this.logger.log( - `Sending bookmark_updated to User: ${userId}, Target Browser: ${conn.browser}`, - ); - conn.socket.emit('bookmark_updated', { browser: sourceBrowser }); - } - } + this.logger.log( + `Sending bookmark_updated to User: ${userId}, except Browser: ${sourceBrowser}`, + ); + this.server + .to(`user:${userId}`) + .except(`user:${userId}:${sourceBrowser}`) + .emit('bookmark_updated', { browser: sourceBrowser }); } // 휴지통 비우기 알림 notifyTrashEmptied(userId: string) { - const connections = this.activeConnections.get(userId); - if (!connections) return; - - for (const conn of connections) { - this.logger.log( - `Sending bookmark_trash_emptied to User: ${userId}, Browser: ${conn.browser}`, - ); - conn.socket.emit('bookmark_trash_emptied'); - } + this.logger.log(`Sending bookmark_trash_emptied to User: ${userId}`); + this.server.to(`user:${userId}`).emit('bookmark_trash_emptied'); } // 특정 사용자의 특정 브라우저 소켓으로 크로스 브라우저 이동 명령 전송 sendCrossBrowserMove(userId: string, targetBrowser: BrowserType, data: CrossBrowserMovePayload) { - const connections = this.activeConnections.get(userId); - if (!connections) return; - - for (const conn of connections) { - if (conn.browser === targetBrowser) { - this.logger.log( - `Sending bookmark_cross_moved [${data.action}] to User: ${userId}, Browser: ${conn.browser}`, - ); - conn.socket.emit('bookmark_cross_moved', data); - } - } + this.logger.log( + `Sending bookmark_cross_moved [${data.action}] to User: ${userId}, Browser: ${targetBrowser}`, + ); + this.server.to(`user:${userId}:${targetBrowser}`).emit('bookmark_cross_moved', data); } // 특정 브라우저에 네이티브 북마크 조작 명령 전송 sendNativeAction(userId: string, targetBrowser: BrowserType, data: BookmarkNativeActionPayload) { - const connections = this.activeConnections.get(userId); - if (!connections) return; - - for (const conn of connections) { - if (conn.browser === targetBrowser) { - this.logger.log( - `Sending bookmark_native_action [${data.action}] to User: ${userId}, Browser: ${conn.browser}`, - ); - conn.socket.emit('bookmark_native_action', data); - } - } + this.logger.log( + `Sending bookmark_native_action [${data.action}] to User: ${userId}, Browser: ${targetBrowser}`, + ); + this.server.to(`user:${userId}:${targetBrowser}`).emit('bookmark_native_action', data); } } diff --git a/apps/server/src/bookmark/services/bookmark.service.spec.ts b/apps/server/src/bookmark/services/bookmark.service.spec.ts index 29b9e98..bbb942b 100644 --- a/apps/server/src/bookmark/services/bookmark.service.spec.ts +++ b/apps/server/src/bookmark/services/bookmark.service.spec.ts @@ -3,6 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { ActivityType, BrowserType } from '@prisma/client'; import type { PartialServiceMock, ServiceMock } from '../../../test/mock.types'; import { ActivityRepository } from '../../analytics/activity.repository'; +import { RedisService } from '../../common/redis/redis.service'; import { PrismaService } from '../../prisma/prisma.service'; import { BookmarkGateway } from '../gateways/bookmark.gateway'; import { BookmarkEventHandler } from '../handlers/bookmark-event.handler'; @@ -22,6 +23,7 @@ describe('BookmarkService', () => { let eventHandlerMock: ServiceMock; let gatewayMock: ServiceMock; let activityMock: ServiceMock; + let redisMock: ServiceMock; // $transaction 콜백에 주입되는 트랜잭션 클라이언트 (user 조작·raw 쿼리 흉내) let txMock: { user: { findUnique: jest.Mock; update: jest.Mock }; $queryRaw: jest.Mock }; @@ -70,6 +72,12 @@ describe('BookmarkService', () => { record: jest.fn().mockResolvedValue(undefined), }; + const mockRedis: PartialServiceMock = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(0), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ BookmarkService, @@ -79,6 +87,7 @@ describe('BookmarkService', () => { { provide: BookmarkEventHandler, useValue: mockEventHandler }, { provide: BookmarkGateway, useValue: mockGateway }, { provide: ActivityRepository, useValue: mockActivity }, + { provide: RedisService, useValue: mockRedis }, ], }).compile(); @@ -88,6 +97,7 @@ describe('BookmarkService', () => { eventHandlerMock = module.get>(BookmarkEventHandler); gatewayMock = module.get>(BookmarkGateway); activityMock = module.get>(ActivityRepository); + redisMock = module.get>(RedisService); }); afterEach(() => { @@ -95,7 +105,7 @@ describe('BookmarkService', () => { }); describe('syncBookmarks', () => { - it('기존 북마크 삭제 → 가상 루트 생성 → 정규화 노드 삽입 후 알림을 보낸다', async () => { + it('기존 북마크 삭제 → 가상 루트 생성 → 정규화 노드 삽입 후 캐시 무효화 및 알림을 보낸다', async () => { txMock.user.findUnique.mockResolvedValue({ initializedBrowsers: [] }); const nodes = [ { @@ -115,62 +125,54 @@ describe('BookmarkService', () => { expect.objectContaining({ browserBookmarkId: 'chrome-root', isFolder: true }), txMock, ); - // 최상위 노드는 가상 루트를 부모로 정규화 expect(repoMock.createMany).toHaveBeenCalledWith( [expect.objectContaining({ browserBookmarkId: 'n1', parentId: 'chrome-root' })], txMock, ); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.notifyBookmarkUpdate).toHaveBeenCalledWith(userId, browser); }); - - it('아직 초기화되지 않은 브라우저면 initializedBrowsers에 추가한다', async () => { - txMock.user.findUnique.mockResolvedValue({ initializedBrowsers: [] }); - - await service.syncBookmarks(userId, browser, []); - - expect(txMock.user.update).toHaveBeenCalledWith({ - where: { id: userId }, - data: { initializedBrowsers: { push: browser } }, - }); - }); - - it('이미 초기화된 브라우저면 initializedBrowsers를 갱신하지 않는다', async () => { - txMock.user.findUnique.mockResolvedValue({ initializedBrowsers: [browser] }); - - await service.syncBookmarks(userId, browser, []); - - expect(txMock.user.update).not.toHaveBeenCalled(); - }); }); describe('handleBookmarkEvent', () => { - it('이벤트 핸들러에 위임하고 트랜잭션 후 알림을 보낸다', async () => { + it('이벤트 핸들러에 위임하고 트랜잭션 후 캐시를 무효화하고 알림을 보낸다', async () => { const event = { action: 'create' as const, data: { id: 'x' } }; await service.handleBookmarkEvent(userId, browser, event); expect(eventHandlerMock.handle).toHaveBeenCalledWith(userId, browser, event, txMock); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.notifyBookmarkUpdate).toHaveBeenCalledWith(userId, browser); }); }); describe('getBookmarks', () => { - it('browser가 주어지면 해당 브라우저로 필터링한다', async () => { - await service.getBookmarks(userId, browser); + it('캐시 히트 시 DB를 조회하지 않고 캐시 데이터를 즉시 반환한다', async () => { + const mockCachedData = [{ id: 'cached-id', title: 'Cached Bookmark' }]; + redisMock.get.mockResolvedValue(JSON.stringify(mockCachedData)); - expect(repoMock.findMany).toHaveBeenCalledWith({ - where: { userId, isDeleted: false, browser }, - orderBy: [{ parentId: 'asc' }, { index: 'asc' }], - }); + const result = await service.getBookmarks(userId, browser); + + expect(redisMock.get).toHaveBeenCalledWith(`cache:user:${userId}:bookmarks:${browser}`); + expect(repoMock.findMany).not.toHaveBeenCalled(); + expect(result).toEqual(mockCachedData); }); - it('browser가 없으면 전체 활성 북마크를 조회한다', async () => { - await service.getBookmarks(userId); + it('캐시 미스 시 DB를 조회하고 그 결과를 Redis 캐시에 쓴 후 반환한다', async () => { + const mockDbData = [{ id: 'db-id', title: 'DB Bookmark' }]; + redisMock.get.mockResolvedValue(null); + repoMock.findMany.mockResolvedValue(mockDbData); - expect(repoMock.findMany).toHaveBeenCalledWith({ - where: { userId, isDeleted: false }, - orderBy: [{ parentId: 'asc' }, { index: 'asc' }], - }); + const result = await service.getBookmarks(userId, browser); + + expect(redisMock.get).toHaveBeenCalledWith(`cache:user:${userId}:bookmarks:${browser}`); + expect(repoMock.findMany).toHaveBeenCalled(); + expect(redisMock.set).toHaveBeenCalledWith( + `cache:user:${userId}:bookmarks:${browser}`, + JSON.stringify(mockDbData), + 3600, + ); + expect(result).toEqual(mockDbData); }); }); @@ -197,7 +199,7 @@ describe('BookmarkService', () => { await expect(service.moveCrossBrowser(userId, dto)).rejects.toThrow(BadRequestException); }); - it('리프 이동 시 노드를 갱신하고 양쪽 브라우저에 소켓 이동 명령을 보낸다', async () => { + it('리프 이동 시 노드를 갱신하고 캐시 무효화 및 양쪽 브라우저에 소켓 이동 명령을 보낸다', async () => { repoMock.findUniqueOrThrow.mockResolvedValue({ isFolder: false, parentId: 'old-parent', @@ -216,7 +218,7 @@ describe('BookmarkService', () => { { browser: dto.targetBrowser, parentId: 'target-folder', index: 0 }, txMock, ); - // 출발지에는 remove, 도착지에는 create 명령 + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.sendCrossBrowserMove).toHaveBeenCalledWith( userId, dto.sourceBrowser, @@ -231,7 +233,7 @@ describe('BookmarkService', () => { }); describe('updateBookmarkMetadata', () => { - it('제목이 바뀌면 네이티브 업데이트 명령을 보내고 편집 활동을 기록한다', async () => { + it('제목이 바뀌면 네이티브 업데이트 명령을 보내고 캐시를 무효화하고 편집 활동을 기록한다', async () => { repoMock.findUniqueOrThrow.mockResolvedValue({ id: 'uuid', isFolder: false, @@ -248,6 +250,7 @@ describe('BookmarkService', () => { 'node-id', expect.objectContaining({ title: 'New' }), ); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.sendNativeAction).toHaveBeenCalledWith( userId, browser, @@ -255,42 +258,28 @@ describe('BookmarkService', () => { ); expect(activityMock.record).toHaveBeenCalledWith(userId, ActivityType.EDIT, 'uuid'); }); - - it('제목·URL이 그대로면 네이티브 업데이트 명령을 보내지 않는다', async () => { - repoMock.findUniqueOrThrow.mockResolvedValue({ - id: 'uuid', - isFolder: false, - title: 'Same', - url: 'https://x.com', - }); - const dto = { title: 'Same', url: 'https://x.com', tags: [] }; - - await service.updateBookmarkMetadata(userId, browser, 'node-id', dto); - - expect(gatewayMock.sendNativeAction).not.toHaveBeenCalled(); - // 변경이 없어도 편집 활동 자체는 기록한다 - expect(activityMock.record).toHaveBeenCalledWith(userId, ActivityType.EDIT, 'uuid'); - }); }); describe('setFavorite', () => { - it('즐겨찾기 플래그를 갱신하고 알림을 보낸다', async () => { + it('즐겨찾기 플래그를 갱신하고 캐시를 무효화하고 알림을 보낸다', async () => { await service.setFavorite(userId, browser, 'node-id', true); expect(repoMock.update).toHaveBeenCalledWith(userId, browser, 'node-id', { isFavorite: true, }); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.notifyBookmarkUpdate).toHaveBeenCalledWith(userId, browser); }); }); describe('softDeleteBookmark', () => { - it('휴지통 서비스에 위임하고 삭제 활동 기록 및 네이티브 삭제 명령을 보낸다', async () => { + it('휴지통 서비스에 위임하고 캐시 무효화 및 삭제 활동 기록/네이티브 삭제 명령을 보낸다', async () => { repoMock.findUnique.mockResolvedValue({ id: 'uuid' }); await service.softDeleteBookmark(userId, browser, 'node-id'); expect(trashMock.softDeleteBookmark).toHaveBeenCalledWith(userId, browser, 'node-id'); + expect(redisMock.del).toHaveBeenCalled(); expect(activityMock.record).toHaveBeenCalledWith(userId, ActivityType.DELETE, 'uuid'); expect(gatewayMock.sendNativeAction).toHaveBeenCalledWith( userId, @@ -298,37 +287,20 @@ describe('BookmarkService', () => { expect.objectContaining({ action: 'delete', browserBookmarkId: 'node-id' }), ); }); - - it('노드를 찾지 못하면 활동을 기록하지 않는다', async () => { - repoMock.findUnique.mockResolvedValue(null); - - await service.softDeleteBookmark(userId, browser, 'node-id'); - - expect(activityMock.record).not.toHaveBeenCalled(); - }); - }); - - describe('recordVisit', () => { - it('방문 활동을 기록한다', async () => { - repoMock.findUniqueOrThrow.mockResolvedValue({ id: 'uuid' }); - - await service.recordVisit(userId, browser, 'node-id'); - - expect(activityMock.record).toHaveBeenCalledWith(userId, ActivityType.VISIT, 'uuid'); - }); }); describe('emptyTrashBookmarks', () => { - it('휴지통을 비우고 비움 알림을 보낸다', async () => { + it('휴지통을 비우고 캐시 무효화 및 비움 알림을 보낸다', async () => { await service.emptyTrashBookmarks(userId); expect(trashMock.emptyTrashBookmarks).toHaveBeenCalledWith(userId); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.notifyTrashEmptied).toHaveBeenCalledWith(userId); }); }); describe('restoreBookmark', () => { - it('복구 후 네이티브 복구 명령과 알림을 보낸다', async () => { + it('복구 후 캐시를 무효화하고 네이티브 복구 명령과 알림을 보낸다', async () => { trashMock.restoreBookmark.mockResolvedValue({ restoredNode: { isFolder: false, @@ -342,6 +314,7 @@ describe('BookmarkService', () => { await service.restoreBookmark(userId, browser, 'node-id'); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.sendNativeAction).toHaveBeenCalledWith( userId, browser, @@ -352,14 +325,15 @@ describe('BookmarkService', () => { }); describe('rekeyBookmarks', () => { - it('매핑이 비어있으면 트랜잭션도 알림도 실행하지 않는다', async () => { + it('매핑이 비어있으면 트랜잭션도 알림도 캐시 무효화도 실행하지 않는다', async () => { await service.rekeyBookmarks(userId, browser, []); expect(repoMock.rekey).not.toHaveBeenCalled(); + expect(redisMock.del).not.toHaveBeenCalled(); expect(gatewayMock.notifyBookmarkUpdate).not.toHaveBeenCalled(); }); - it('각 매핑에 대해 rekey를 호출하고 알림을 보낸다', async () => { + it('각 매핑에 대해 rekey를 호출하고 캐시 무효화 및 알림을 보낸다', async () => { const mappings = [ { oldId: 'o1', newId: 'n1' }, { oldId: 'o2', newId: 'n2' }, @@ -368,8 +342,7 @@ describe('BookmarkService', () => { await service.rekeyBookmarks(userId, browser, mappings); expect(repoMock.rekey).toHaveBeenCalledTimes(2); - expect(repoMock.rekey).toHaveBeenCalledWith(userId, browser, 'o1', 'n1', txMock); - expect(repoMock.rekey).toHaveBeenCalledWith(userId, browser, 'o2', 'n2', txMock); + expect(redisMock.del).toHaveBeenCalled(); expect(gatewayMock.notifyBookmarkUpdate).toHaveBeenCalledWith(userId, browser); }); }); diff --git a/apps/server/src/bookmark/services/bookmark.service.ts b/apps/server/src/bookmark/services/bookmark.service.ts index 71c032b..682f541 100644 --- a/apps/server/src/bookmark/services/bookmark.service.ts +++ b/apps/server/src/bookmark/services/bookmark.service.ts @@ -2,6 +2,7 @@ import type { BookmarkNativeEventAction, NormalizedBookmarkNode } from '@dookmar import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { ActivityType, Bookmark, BrowserType } from '@prisma/client'; import { ActivityRepository } from '../../analytics/activity.repository'; +import { RedisService } from '../../common/redis/redis.service'; import { PrismaService } from '../../prisma/prisma.service'; import { BookmarkGateway } from '../gateways/bookmark.gateway'; import { BookmarkEventHandler } from '../handlers/bookmark-event.handler'; @@ -25,6 +26,7 @@ export class BookmarkService { private readonly bookmarkEventHandler: BookmarkEventHandler, private readonly bookmarkGateway: BookmarkGateway, private readonly activityRepository: ActivityRepository, + private readonly redisService: RedisService, ) {} // 동기화 요청으로 온 북마크 노드들의 최상위 부모를 가상 루트 ID로 세팅하고 DB 규격에 맞춰 정규화 @@ -54,6 +56,22 @@ export class BookmarkService { }); } + /** + * 사용자의 북마크 캐시 일괄 무효화 (DEL 활용 O(1)) + */ + private async invalidateUserBookmarkCache(userId: string): Promise { + const keysToDelete = [ + ...Object.values(BrowserType).map((b) => `cache:user:${userId}:bookmarks:${b}`), + `cache:user:${userId}:bookmarks:all`, + ]; + try { + await this.redisService.del(...keysToDelete); + this.logger.log(`Successfully invalidated bookmark cache for user ${userId}`); + } catch (err) { + this.logger.error(`Failed to invalidate bookmark cache for user ${userId}:`, err); + } + } + /** * 전체 북마크 트리 벌크 동기화 (트랜잭션 조율) */ @@ -116,6 +134,7 @@ export class BookmarkService { this.logger.log( `Successfully completed bulk bookmark sync for user: ${userId}, browser: ${browser}`, ); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } @@ -135,14 +154,26 @@ export class BookmarkService { await this.bookmarkEventHandler.handle(userId, browser, event, tx); }); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } /** - * 사용자별 활성 북마크 조회 (isDeleted: false 필터링 기본 적용) + * 사용자별 활성 북마크 조회 (isDeleted: false 필터링 기본 적용 - Redis 캐싱 적용) */ async getBookmarks(userId: string, browser?: BrowserType): Promise { - return this.bookmarkRepository.findMany({ + const cacheKey = `cache:user:${userId}:bookmarks:${browser || 'all'}`; + try { + const cached = await this.redisService.get(cacheKey); + if (cached) { + this.logger.log(`Cache Hit for bookmarks of user ${userId} (browser: ${browser || 'all'})`); + return JSON.parse(cached); + } + } catch (err) { + this.logger.error(`Failed to read from Redis cache for key ${cacheKey}:`, err); + } + + const bookmarks = await this.bookmarkRepository.findMany({ where: { userId, isDeleted: false, @@ -150,6 +181,14 @@ export class BookmarkService { }, orderBy: [{ parentId: 'asc' }, { index: 'asc' }], }); + + try { + await this.redisService.set(cacheKey, JSON.stringify(bookmarks), 3600); // 1시간 캐시 + } catch (err) { + this.logger.error(`Failed to write to Redis cache for key ${cacheKey}:`, err); + } + + return bookmarks; } /** @@ -251,7 +290,8 @@ export class BookmarkService { } }); - // 트랜잭션 경계 바깥에서 소켓 알림 발송 + // 트랜잭션 경계 바깥에서 소켓 알림 발송 및 캐시 무효화 + await this.invalidateUserBookmarkCache(userId); if (sourceNode.isFolder) { this.bookmarkGateway.sendCrossBrowserMove(userId, sourceBrowser, { action: 'remove', @@ -326,9 +366,10 @@ export class BookmarkService { }); } - // 편집 활동 기록 (네이티브 onChanged 에코는 별도로 집계하지 않아 이중 집계를 피한다) + // 편집 활동 기록 (네이티브 onChanged 에코는 별도로 집계하지 않아 이중 집계 방지) await this.activityRepository.record(userId, ActivityType.EDIT, bookmark.id); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } @@ -342,6 +383,7 @@ export class BookmarkService { isFavorite: boolean, ): Promise { await this.bookmarkRepository.update(userId, browser, browserBookmarkId, { isFavorite }); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } @@ -367,12 +409,13 @@ export class BookmarkService { browserBookmarkId, }); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } /** - * 방문 기록 — 사이드패널에서 북마크를 클릭해 열 때 호출된다. - * 네이티브 북마크바·주소창 클릭은 추적할 수 없어, 우리 UI를 통한 방문만 집계된다. + * 방문 기록 — 사이드패널에서 북마크를 클릭해 열 때 호출 + * 네이티브 북마크바·주소창 클릭은 추적할 수 없어, 우리 UI를 통한 방문만 집계 */ async recordVisit( userId: string, @@ -399,7 +442,7 @@ export class BookmarkService { */ async emptyTrashBookmarks(userId: string): Promise { await this.bookmarkTrashService.emptyTrashBookmarks(userId); - + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyTrashEmptied(userId); } @@ -436,14 +479,15 @@ export class BookmarkService { }, }); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } /** * 네이티브 id 재키 (복원 후 동기화) * 복원 시 브라우저가 새 네이티브 id를 부여하므로, 클라이언트가 보고한 - * {옛 id → 새 id} 매핑으로 DB 행의 식별자를 일치. (onCreated 에코는 - * 클라이언트에서 억제되어 중복 행 미발생.) + * {옛 id → 새 id} 매핑으로 DB 행의 식별자 일치 (onCreated 에코는 + * 클라이언트에서 억제되어 중복 행 미발생) */ async rekeyBookmarks( userId: string, @@ -458,6 +502,7 @@ export class BookmarkService { } }); + await this.invalidateUserBookmarkCache(userId); this.bookmarkGateway.notifyBookmarkUpdate(userId, browser); } } diff --git a/apps/server/src/common/adapters/redis-io.adapter.ts b/apps/server/src/common/adapters/redis-io.adapter.ts new file mode 100644 index 0000000..1f8895d --- /dev/null +++ b/apps/server/src/common/adapters/redis-io.adapter.ts @@ -0,0 +1,56 @@ +import { INestApplicationContext, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import Redis from 'ioredis'; +import { ServerOptions } from 'socket.io'; + +export class RedisIoAdapter extends IoAdapter { + private readonly logger = new Logger(RedisIoAdapter.name); + private adapterConstructor!: ReturnType; + + constructor(private readonly app: INestApplicationContext) { + super(app); + } + + async connectToRedis(): Promise { + const configService = this.app.get(ConfigService); + const host = configService.get('REDIS_HOST', 'localhost'); + const port = Number(configService.get('REDIS_PORT', 6379)); + const password = configService.get('REDIS_PASSWORD'); + const tlsEnabled = configService.get('REDIS_TLS') === 'true'; + + this.logger.log( + `Initializing Redis Adapter connection to ${host}:${port} (TLS: ${tlsEnabled})`, + ); + + const redisOptions = { + host, + port, + password: password || undefined, + tls: tlsEnabled ? {} : undefined, + lazyConnect: true, + }; + + const pubClient = new Redis(redisOptions); + const subClient = new Redis(redisOptions); + + pubClient.on('error', (err) => this.logger.error('Redis Adapter PubClient Error:', err)); + subClient.on('error', (err) => this.logger.error('Redis Adapter SubClient Error:', err)); + + try { + await Promise.all([pubClient.connect(), subClient.connect()]); + this.adapterConstructor = createAdapter(pubClient, subClient); + this.logger.log('Redis Adapter successfully connected and initialized'); + } catch (err) { + this.logger.error('Failed to initialize Redis Adapter connections:', err); + throw err; + } + } + + override createIOServer(port: number, options?: ServerOptions): any { + const server = super.createIOServer(port, options); + server.adapter(this.adapterConstructor); + return server; + } +} diff --git a/apps/server/src/common/redis/redis.module.ts b/apps/server/src/common/redis/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/apps/server/src/common/redis/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/server/src/common/redis/redis.service.spec.ts b/apps/server/src/common/redis/redis.service.spec.ts new file mode 100644 index 0000000..6b77caa --- /dev/null +++ b/apps/server/src/common/redis/redis.service.spec.ts @@ -0,0 +1,80 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import RedisMock from 'ioredis-mock'; +import { RedisService } from './redis.service'; + +jest.mock('ioredis', () => { + return jest.fn().mockImplementation(() => { + return new RedisMock(); + }); +}); + +describe('RedisService', () => { + let service: RedisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisService, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === 'REDIS_HOST') return 'localhost'; + if (key === 'REDIS_PORT') return 6379; + if (key === 'REDIS_TLS') return 'false'; + return defaultValue; + }), + }, + }, + ], + }).compile(); + + service = module.get(RedisService); + service.onModuleInit(); + }); + + afterEach(async () => { + await service.onModuleDestroy(); + }); + + it('정의되어 있어야 한다', () => { + expect(service).toBeDefined(); + }); + + describe('캐시 연산 (String)', () => { + it('값을 저장하고 가져올 수 있어야 한다', async () => { + await service.set('test-key', 'test-value'); + const val = await service.get('test-key'); + expect(val).toBe('test-value'); + }); + + it('값을 삭제할 수 있어야 한다', async () => { + await service.set('test-key', 'test-value'); + await service.del('test-key'); + const val = await service.get('test-key'); + expect(val).toBeNull(); + }); + }); + + describe('정렬된 집합 (ZSET) 연산', () => { + it('zadd로 값을 추가하고 zrange로 조회할 수 있어야 한다', async () => { + await service.zadd('z-key', 100, 'member-1'); + await service.zadd('z-key', 200, 'member-2'); + + const members = await service.zrange('z-key', 0, -1); + expect(members).toEqual(['member-1', 'member-2']); + }); + + it('zremrangebyscore로 만료 임계값 이하의 멤버들을 삭제할 수 있어야 한다', async () => { + await service.zadd('z-key', 100, 'member-1'); + await service.zadd('z-key', 200, 'member-2'); + + // 150 이하 멤버 삭제 + await service.zremrangebyscore('z-key', '-inf', 150); + + const members = await service.zrange('z-key', 0, -1); + expect(members).toEqual(['member-2']); + }); + }); +}); diff --git a/apps/server/src/common/redis/redis.service.ts b/apps/server/src/common/redis/redis.service.ts new file mode 100644 index 0000000..d5253f5 --- /dev/null +++ b/apps/server/src/common/redis/redis.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + private client!: Redis; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + const host = this.configService.get('REDIS_HOST', 'localhost'); + const port = Number(this.configService.get('REDIS_PORT', 6379)); + const password = this.configService.get('REDIS_PASSWORD'); + const tlsEnabled = this.configService.get('REDIS_TLS') === 'true'; + + this.logger.log(`Connecting to Redis at ${host}:${port} (TLS: ${tlsEnabled})`); + + this.client = new Redis({ + host, + port, + password: password || undefined, + tls: tlsEnabled ? {} : undefined, + maxRetriesPerRequest: null, + }); + + this.client.on('connect', () => { + this.logger.log('Successfully connected to Redis'); + }); + + this.client.on('error', (err) => { + this.logger.error('Redis connection error:', err); + }); + } + + async onModuleDestroy() { + this.logger.log('Disconnecting from Redis...'); + await this.client.quit(); + } + + getClient(): Redis { + return this.client; + } + + // 캐시 연산 (String) + + async get(key: string): Promise { + return this.client.get(key); + } + + async set(key: string, value: string, ttlSeconds?: number): Promise { + if (ttlSeconds) { + await this.client.set(key, value, 'EX', ttlSeconds); + } else { + await this.client.set(key, value); + } + } + + async del(...keys: string[]): Promise { + if (keys.length === 0) return 0; + return this.client.del(...keys); + } + + // Sorted Set (ZSET) 연산 + + async zadd(key: string, score: number, member: string): Promise { + return this.client.zadd(key, score, member); + } + + async zrem(key: string, ...members: string[]): Promise { + if (members.length === 0) return 0; + return this.client.zrem(key, ...members); + } + + async zremrangebyscore(key: string, min: string | number, max: string | number): Promise { + return this.client.zremrangebyscore(key, min, max); + } + + async zrange(key: string, start: number, stop: number): Promise { + return this.client.zrange(key, start, stop); + } +} diff --git a/apps/server/src/config/env.validation.ts b/apps/server/src/config/env.validation.ts index fe14fc4..b3b3428 100644 --- a/apps/server/src/config/env.validation.ts +++ b/apps/server/src/config/env.validation.ts @@ -38,6 +38,20 @@ class EnvironmentVariables { @IsOptional() @IsString() PORT?: string; + + @IsString() + REDIS_HOST!: string; + + @IsString() + REDIS_PORT!: string; + + @IsOptional() + @IsString() + REDIS_PASSWORD?: string; + + @IsOptional() + @IsString() + REDIS_TLS?: string; } export function validateEnv(config: Record): Record { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 66195e5..734ca9e 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -3,6 +3,7 @@ import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { RedisIoAdapter } from './common/adapters/redis-io.adapter'; import { setupCors } from './config/cors'; async function bootstrap() { @@ -13,6 +14,10 @@ async function bootstrap() { app.use(cookieParser()); setupCors(app); + const redisIoAdapter = new RedisIoAdapter(app); + await redisIoAdapter.connectToRedis(); + app.useWebSocketAdapter(redisIoAdapter); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/docker-compose.yml b/docker-compose.yml index 3103b6b..771df53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,11 +13,20 @@ services: volumes: - postgres_data:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql + redis: + image: redis:7-alpine + container_name: bookmark-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data networks: - monorepo-net volumes: postgres_data: + redis_data: networks: monorepo-net: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32a5cd7..60abded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: '@prisma/client': specifier: ^7.6.0 version: 7.6.0(prisma@7.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.8) bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -162,6 +165,9 @@ importers: helmet: specifier: ^8.2.0 version: 8.2.0 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 nodemailer: specifier: ^9.0.0 version: 9.0.0 @@ -211,6 +217,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/ioredis-mock': + specifier: ^8.2.7 + version: 8.2.7(ioredis@5.11.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -235,6 +244,9 @@ importers: globals: specifier: ^16.0.0 version: 16.4.0 + ioredis-mock: + specifier: ^8.13.1 + version: 8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.11.1))(ioredis@5.11.1) jest: specifier: ^30.0.0 version: 30.3.0(@types/node@22.19.15)(node-notifier@10.0.1)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) @@ -1222,6 +1234,12 @@ packages: '@types/node': optional: true + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1809,6 +1827,12 @@ packages: '@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.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2025,6 +2049,11 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/ioredis-mock@8.2.7': + resolution: {integrity: sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==} + peerDependencies: + ioredis: '>=5' + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2780,6 +2809,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3296,6 +3329,14 @@ packages: picomatch: optional: true + fengari-interop@0.1.4: + resolution: {integrity: sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.5: + resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + file-type@21.3.2: resolution: {integrity: sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==} engines: {node: '>=20'} @@ -3593,6 +3634,17 @@ packages: resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ioredis-mock@8.13.1: + resolution: {integrity: sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4372,6 +4424,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4815,6 +4870,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -4823,6 +4882,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5071,6 +5138,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqlstring@2.3.3: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} @@ -5082,6 +5152,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5416,6 +5489,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'} @@ -6545,6 +6622,10 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 + '@ioredis/as-callback@3.0.0': {} + + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7216,6 +7297,15 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.8)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.8 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -7440,6 +7530,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/ioredis-mock@8.2.7(ioredis@5.11.1)': + dependencies: + ioredis: 5.11.1 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -8256,6 +8350,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -8748,6 +8844,16 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fengari-interop@0.1.4(fengari@0.1.5): + dependencies: + fengari: 0.1.5 + + fengari@0.1.5: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.2.5 + file-type@21.3.2: dependencies: '@tokenizer/inflate': 0.4.1 @@ -9057,6 +9163,28 @@ snapshots: ini@4.1.3: {} + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.11.1))(ioredis@5.11.1): + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.10.0 + '@types/ioredis-mock': 8.2.7(ioredis@5.11.1) + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.11.1 + semver: 7.7.4 + + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-absolute@0.1.7: @@ -9995,6 +10123,8 @@ snapshots: normalize-path@3.0.0: {} + notepack.io@3.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -10454,6 +10584,8 @@ snapshots: readdirp@5.0.0: {} + readline-sync@1.4.10: {} + real-require@0.2.0: {} redent@3.0.0: @@ -10461,6 +10593,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} registry-auth-token@5.1.1: @@ -10782,6 +10920,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + sqlstring@2.3.3: {} stack-utils@2.0.6: @@ -10790,6 +10930,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -11088,6 +11230,8 @@ snapshots: uid2@0.0.4: {} + uid2@1.0.0: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0