diff --git a/.env.sample b/.env.sample index 3e3042b..606b966 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,14 @@ POSTGRES_PASSWORD=vd2 POSTGRES_HOST=localhost POSTGRES_PORT=5432 +# Cache (redis) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=notion-check-plz +REDIS_DB=0 +REDIS_KEY_PREFIX=vd2:cache: +CACHE_DEFAULT_TTL=300 + # Slack Notification SLACK_WEBHOOK_URL=https://hooks.slack.com/services diff --git a/README.md b/README.md index 92af825..d176d54 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ pnpm dev ```bash pnpm dev # 개발 서버 실행 pnpm test # 테스트 실행 -pnpm lint # 린트 검사 -pnpm lint:fix # 린트 자동 수정 +pnpm lint # 린트 검사 (eslint + prettier) +pnpm lint-staged # 린트 자동 수정 pnpm build # 프로젝트 빌드 pnpm start # 빌드된 프로젝트 시작 diff --git a/eslint.config.mjs b/eslint.config.mjs index 11d576b..84aff4d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,7 @@ export default typescriptEslint.config( }, { selector: 'variable', - format: ['camelCase'], + format: ['camelCase', 'UPPER_CASE'], leadingUnderscore: 'allow', }, { diff --git a/package.json b/package.json index 3ee08bf..01320b5 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,12 @@ { "name": "velog-dashboard", "version": "1.0.0", - "description": "", + "description": "Velog Dashboard Project, velog의 모든 게시글, 통계 데이터를 한 눈에 편하게 확인할 수 있는 대시보드입니다.", "main": "index.js", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc && tsc-alias -p tsconfig.json", - "lint": "eslint src/**/*.ts", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write src/**/*.ts", + "lint": "eslint src/**/*.ts && prettier --check src/**/*.ts", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", @@ -20,8 +18,21 @@ "prettier --write" ] }, - "keywords": [], - "author": "", + "keywords": [ + "velog", + "dashboard", + "analytics", + "blog", + "monitoring", + "typescript", + "express", + "node.js", + "redis", + "postgresql", + "api", + "swagger" + ], + "author": "Nuung", "license": "ISC", "dependencies": { "@sentry/node": "^9.36.0", @@ -33,6 +44,7 @@ "dotenv": "^16.4.5", "express": "^4.21.1", "pg": "^8.13.1", + "redis": "^5.6.0", "reflect-metadata": "^0.2.2", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", @@ -65,4 +77,4 @@ "typescript-eslint": "^8.15.0", "typescript-transform-paths": "^3.5.3" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 155f2db..04e3f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: pg: specifier: ^8.13.1 version: 8.13.1 + redis: + specifier: ^5.6.0 + version: 5.6.0 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -822,6 +825,34 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@redis/bloom@5.6.0': + resolution: {integrity: sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.6.0 + + '@redis/client@5.6.0': + resolution: {integrity: sha512-wmP9kCFElCSr4MM4+1E4VckDuN4wLtiXSM/J0rKVQppajxQhowci89RGZr2OdLualowb8SRJ/R6OjsXrn9ZNFA==} + engines: {node: '>= 18'} + + '@redis/json@5.6.0': + resolution: {integrity: sha512-YQN9ZqaSDpdLfJqwzcF4WeuJMGru/h4WsV7GeeNtXsSeyQjHTyDxrd48xXfRRJGv7HitA7zGnzdHplNeKOgrZA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.6.0 + + '@redis/search@5.6.0': + resolution: {integrity: sha512-sLgQl92EyMVNHtri5K8Q0j2xt9c0cO9HYurXz667Un4xeUYR+B/Dw5lLG35yqO7VvVxb9amHJo9sAWumkKZYwA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.6.0 + + '@redis/time-series@5.6.0': + resolution: {integrity: sha512-tXABmN1vu4aTNL3WI4Iolpvx/5jgil2Bs31ozvKblT+jkUoRkk8ykmYo9Pv/Mp7Gk6/Qkr/2rMgVminrt/4BBQ==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.6.0 + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1266,6 +1297,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2530,6 +2565,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redis@5.6.0: + resolution: {integrity: sha512-0x3pM3SlYA5azdNwO8qgfMBzoOqSqr9M+sd1hojbcn0ZDM5zsmKeMM+zpTp6LIY+mbQomIc/RTTQKuBzr8QKzQ==} + engines: {node: '>= 18'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -3800,6 +3839,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@redis/bloom@5.6.0(@redis/client@5.6.0)': + dependencies: + '@redis/client': 5.6.0 + + '@redis/client@5.6.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.6.0(@redis/client@5.6.0)': + dependencies: + '@redis/client': 5.6.0 + + '@redis/search@5.6.0(@redis/client@5.6.0)': + dependencies: + '@redis/client': 5.6.0 + + '@redis/time-series@5.6.0(@redis/client@5.6.0)': + dependencies: + '@redis/client': 5.6.0 + '@scarf/scarf@1.4.0': {} '@sentry/core@9.36.0': {} @@ -4370,6 +4429,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.2: {} @@ -5777,6 +5838,14 @@ snapshots: dependencies: picomatch: 2.3.1 + redis@5.6.0: + dependencies: + '@redis/bloom': 5.6.0(@redis/client@5.6.0) + '@redis/client': 5.6.0 + '@redis/json': 5.6.0(@redis/client@5.6.0) + '@redis/search': 5.6.0(@redis/client@5.6.0) + '@redis/time-series': 5.6.0(@redis/client@5.6.0) + reflect-metadata@0.2.2: {} require-directory@2.1.1: {} diff --git a/src/app.ts b/src/app.ts index 8f97245..d3142bb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,44 +1,92 @@ import 'reflect-metadata'; -import express, { Application } from 'express'; +import express, { Application, Request, Response, NextFunction } from 'express'; import dotenv from 'dotenv'; import cors from 'cors'; import cookieParser from 'cookie-parser'; -import router from './routes'; import swaggerUi from 'swagger-ui-express'; import swaggerJSDoc from 'swagger-jsdoc'; + +import logger from '@/configs/logger.config'; +import router from '@/routes'; +import { NotFoundError } from '@/exception'; + import { options } from '@/configs/swagger.config'; -import { errorHandlingMiddleware } from './middlewares/errorHandling.middleware'; -import { NotFoundError } from './exception'; -import { initSentry } from '@/configs/sentry.config'; +import { initSentry, getSentryStatus } from '@/configs/sentry.config'; +import { initCache, getCacheStatus } from '@/configs/cache.config'; +import { errorHandlingMiddleware } from '@/middlewares/errorHandling.middleware'; dotenv.config(); -// Sentry 초기화 -initSentry(); +initSentry(); // Sentry 초기화 +initCache(); // Redis 캐시 초기화 const app: Application = express(); + // 실제 클라이언트 IP를 알기 위한 trust proxy 설정 -app.set('trust proxy', true); +app.set('trust proxy', process.env.NODE_ENV === 'production'); + const swaggerSpec = swaggerJSDoc(options); app.use(cookieParser()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '10mb' })); // 파일 업로드 대비 +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + app.use( cors({ - origin: process.env.NODE_ENV === 'production' ? process.env.ALLOWED_ORIGINS?.split(',') : 'http://localhost:3000', + origin: process.env.NODE_ENV === 'production' + ? process.env.ALLOWED_ORIGINS?.split(',').map(origin => origin.trim()) + : 'http://localhost:3000', methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'access_token', 'refresh_token'], credentials: true, }), ); -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +// 헬스체크 엔드포인트 +app.get('/health', async (req: Request, res: Response) => { + // 기본 정보 + const healthData = { + status: 'OK', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV, + services: { + sentry: false, + cache: false + } + }; + + // Sentry 상태 확인 + try { + healthData.services.sentry = getSentryStatus(); + } catch (error) { + healthData.services.sentry = false; + logger.error('Failed to health check for sentry:', error); + } + + // Cache 상태 확인 + try { + healthData.services.cache = await getCacheStatus(); + } catch (error) { + healthData.services.cache = false; + logger.error('Failed to health check for cache:', error); + } + + res.status(200).json(healthData); +}); + +// Swagger는 개발 환경에서만 +if (process.env.NODE_ENV !== 'production') { + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +} + app.use('/api', router); -app.use((req) => { - throw new NotFoundError(`${req.url} not found`); + +// 404 에러 핸들링 수정 (throw 대신 next 사용) +app.use((req: Request, res: Response, next: NextFunction) => { + next(new NotFoundError(`${req.url} not found`)); }); app.use(errorHandlingMiddleware); -export default app; +export default app; \ No newline at end of file diff --git a/src/configs/cache.config.ts b/src/configs/cache.config.ts new file mode 100644 index 0000000..eb15382 --- /dev/null +++ b/src/configs/cache.config.ts @@ -0,0 +1,62 @@ +import logger from '@/configs/logger.config'; + +import { ICache, CacheConfig } from '@/modules/cache/cache.type'; +import { RedisCache } from '@/modules/cache/redis.cache'; + +const cacheConfig: CacheConfig = { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD || 'notion-check-plz', + db: parseInt(process.env.REDIS_DB || '0'), + keyPrefix: process.env.REDIS_KEY_PREFIX || 'vd2:cache:', + defaultTTL: parseInt(process.env.CACHE_DEFAULT_TTL || '300'), // 5분 +}; + +// 싱글톤 캐시 인스턴스 (const로 변경하고 null 초기화) +const cacheInstance: ICache = new RedisCache(cacheConfig); +export const cache = cacheInstance; + +// 캐시 상태 추적 변수 +let cacheInitialized = false; + +// 초기화 함수 +export const initCache = async (): Promise => { + try { + await cache.connect(); + cacheInitialized = true; + logger.info('Cache system initialized successfully'); + } catch (error) { + cacheInitialized = false; + logger.error('Failed to initialize cache system:', error); + // 캐시 연결 실패해도 애플리케이션은 계속 실행 + logger.warn('Application will continue without cache'); + } +}; + +// 종료 함수 +export const closeCache = async (): Promise => { + try { + cache.destroy(); + cacheInitialized = false; + logger.info('Cache system closed successfully'); + } catch (error) { + logger.error('Failed to close cache system:', error); + } +}; + +// 캐시 상태 확인 함수 +export const getCacheStatus = async (): Promise => { + if (!cacheInitialized) { + return false; + } + + try { + // Redis ping 명령어로 연결 상태 확인 + await cache.set('health-check', 'ok', 1); // 1초 TTL로 테스트 키 설정 + await cache.get('health-check'); // 읽기 테스트 + return true; + } catch (error) { + logger.warn('Cache health check failed:', error); + return false; + } +}; diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index b8b0773..6a6e954 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -1,6 +1,7 @@ import dotenv from 'dotenv'; import pg from 'pg'; -import logger from './logger.config'; +import logger from '@/configs/logger.config'; + // eslint-disable-next-line @typescript-eslint/naming-convention const { Pool } = pg; diff --git a/src/configs/sentry.config.ts b/src/configs/sentry.config.ts index 8fe63c2..4cf3ebe 100644 --- a/src/configs/sentry.config.ts +++ b/src/configs/sentry.config.ts @@ -3,22 +3,39 @@ import dotenv from 'dotenv'; dotenv.config(); +// Sentry 초기화 상태 추적 +let sentryInitialized = false; + export const initSentry = () => { - Sentry.init({ - dsn: process.env.SENTRY_DSN, - release: process.env.NODE_ENV, - - // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. - tracesSampleRate: 0.1, - - // Setting this option to true will print useful information to the console while you're setting up Sentry. - debug: false, - enabled: true, - - // Capture 100% of the transactions for performance monitoring - integrations: [ - Sentry.httpIntegration(), - Sentry.expressIntegration(), - ], - }); -}; \ No newline at end of file + try { + if (!process.env.SENTRY_DSN) { + sentryInitialized = false; + return; + } + + Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: process.env.NODE_ENV, + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 0.1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + enabled: true, + + // Capture 100% of the transactions for performance monitoring + integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()], + }); + + sentryInitialized = true; + } catch (error) { + sentryInitialized = false; + throw error; + } +}; + +// Sentry 상태 확인 함수 +export const getSentryStatus = (): boolean => { + return sentryInitialized; +}; diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 2b7d043..7f1af81 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -12,7 +12,7 @@ type Token10 = string & { __lengthBrand: 10 }; const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000; export class UserController { - constructor(private userService: UserService) { } + constructor(private userService: UserService) {} /** * 환경 및 쿠키 삭제 여부에 따라 쿠키 옵션을 생성합니다. diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 5a67c66..f9e3b96 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -6,18 +6,14 @@ import { BadRequestError } from '@/exception'; export class WebhookController { private readonly STATUS_EMOJI = { - 'unresolved': '🔴', - 'resolved': '✅', - 'ignored': '🔇', + unresolved: '🔴', + resolved: '✅', + ignored: '🔇', } as const; - handleSentryWebhook: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { + handleSentryWebhook: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { try { - if (req.body?.action !== "created") { + if (req.body?.action !== 'created') { const response = new BadRequestError('Sentry 웹훅 처리에 실패했습니다'); res.status(400).json(response); return; @@ -37,9 +33,12 @@ export class WebhookController { }; private formatSentryMessage(sentryData: SentryWebhookData): string { - const { data: { issue } } = sentryData; + const { + data: { issue }, + } = sentryData; - if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new BadRequestError('Sentry 웹훅 처리에 실패했습니다'); + if (!issue.status || !issue.title || !issue.culprit || !issue.id) + throw new BadRequestError('Sentry 웹훅 처리에 실패했습니다'); const { status, title: issueTitle, culprit, permalink, id } = issue; const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI]; @@ -54,4 +53,4 @@ export class WebhookController { return message; } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 493d64d..44a2252 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,45 @@ -import app from './app'; -import logger from './configs/logger.config'; +import app from '@/app'; +import logger from '@/configs/logger.config'; +import { closeCache } from './configs/cache.config'; -const port = process.env.PORT || 3000; +const port = parseInt(process.env.PORT || '8080', 10); -app.listen(port, () => { - logger.info(`Server is running on http://localhost:${port}`); +const server = app.listen(port, () => { + logger.info(`Server running on port ${port}`); + logger.info(`Environment: ${process.env.NODE_ENV}`); + if (process.env.NODE_ENV !== 'production') { + logger.info(`API Docs: http://localhost:${port}/api-docs`); + } + logger.info(`Health Check: http://localhost:${port}/health`); +}); + +// 기본적인 graceful shutdown 추가 +const gracefulShutdown = async (signal: string) => { + logger.info(`${signal} received, shutting down gracefully`); + await closeCache(); + + server.close(() => { + logger.info('HTTP server closed'); + process.exit(0); + }); + + // 강제 종료 타이머 (10초) + setTimeout(() => { + logger.error('Could not close connections in time, forcefully shutting down'); + process.exit(1); + }, 10000); +}; + +process.on('SIGTERM', async () => gracefulShutdown('SIGTERM')); +process.on('SIGINT', async () => gracefulShutdown('SIGINT')); + +// 예상치 못한 에러 처리 +process.on('uncaughtException', async (error) => { + logger.error('Uncaught Exception:', error); + await gracefulShutdown('UNCAUGHT_EXCEPTION'); +}); + +process.on('unhandledRejection', async (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + await gracefulShutdown('UNHANDLED_REJECTION'); }); diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts index d6e7069..b532274 100644 --- a/src/middlewares/auth.middleware.ts +++ b/src/middlewares/auth.middleware.ts @@ -4,7 +4,7 @@ import logger from '@/configs/logger.config'; import pool from '@/configs/db.config'; import { CustomError, DBError, InvalidTokenError } from '@/exception'; import { VelogJWTPayload, User } from '@/types'; -import crypto from "crypto"; +import crypto from 'crypto'; /** * 요청에서 토큰을 추출하는 함수 @@ -69,36 +69,37 @@ const verifyBearerTokens = () => { /** * Sentry 웹훅 요청의 시그니처 헤더를 검증합니다. - * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, + * HMAC SHA256과 Sentry의 Client Secret를 사용하여 요청 본문을 해시화하고, * Sentry에서 제공하는 시그니처 헤더와 비교하여 요청의 무결성을 확인합니다. */ function verifySentrySignature() { return (req: Request, res: Response, next: NextFunction) => { try { - if (!process.env.SENTRY_CLIENT_SECRET) throw new Error("SENTRY_CLIENT_SECRET가 env에 없습니다"); - - const hmac = crypto.createHmac("sha256", process.env.SENTRY_CLIENT_SECRET); - + if (!process.env.SENTRY_CLIENT_SECRET) throw new Error('SENTRY_CLIENT_SECRET가 env에 없습니다'); + + const hmac = crypto.createHmac('sha256', process.env.SENTRY_CLIENT_SECRET); + // Raw body 사용 - Express에서 파싱되기 전의 원본 데이터 필요 // req.rawBody가 없다면 fallback으로 JSON.stringify 사용 (완벽하지 않음) // @ts-expect-error - rawBody는 커스텀 미들웨어에서 추가되는 속성 const bodyToVerify = req.rawBody || JSON.stringify(req.body); - const sentrySignature = req.headers["sentry-hook-signature"]; - - if (!bodyToVerify) throw new Error("요청 본문이 없습니다."); - if (!sentrySignature) throw new Error("시그니처 헤더가 없습니다."); - - hmac.update(bodyToVerify, "utf8"); - const digest = hmac.digest("hex"); - - if (digest !== sentrySignature) throw new CustomError("유효하지 않은 시그니처 헤더입니다.", "INVALID_SIGNATURE", 400); - + const sentrySignature = req.headers['sentry-hook-signature']; + + if (!bodyToVerify) throw new Error('요청 본문이 없습니다.'); + if (!sentrySignature) throw new Error('시그니처 헤더가 없습니다.'); + + hmac.update(bodyToVerify, 'utf8'); + const digest = hmac.digest('hex'); + + if (digest !== sentrySignature) + throw new CustomError('유효하지 않은 시그니처 헤더입니다.', 'INVALID_SIGNATURE', 400); + next(); } catch (error) { logger.error('시그니처 검증 중 오류가 발생하였습니다. : ', error); next(error); } - } + }; } /** diff --git a/src/middlewares/errorHandling.middleware.ts b/src/middlewares/errorHandling.middleware.ts index 58c66a8..f17edeb 100644 --- a/src/middlewares/errorHandling.middleware.ts +++ b/src/middlewares/errorHandling.middleware.ts @@ -16,10 +16,10 @@ export const errorHandlingMiddleware: ErrorRequestHandler = ( .json({ success: false, message: err.message, error: { code: err.code, statusCode: err.statusCode } }); return; } - + Sentry.captureException(err); logger.error('Internal Server Error'); - + res.status(500).json({ success: false, message: '서버 내부 에러가 발생하였습니다.', diff --git a/src/modules/cache/__test__/redis.cache.test.ts b/src/modules/cache/__test__/redis.cache.test.ts new file mode 100644 index 0000000..0a18490 --- /dev/null +++ b/src/modules/cache/__test__/redis.cache.test.ts @@ -0,0 +1,540 @@ +import { RedisCache } from '@/modules/cache/redis.cache'; +import { CacheConfig } from '@/modules/cache/cache.type'; +import { createClient } from 'redis'; + +// Redis 클라이언트 타입 정의 +interface MockRedisClient { + connect: jest.Mock; + destroy: jest.Mock; + on: jest.Mock; + get: jest.Mock; + set: jest.Mock; + setEx: jest.Mock; + del: jest.Mock; + exists: jest.Mock; + keys: jest.Mock; + scan: jest.Mock; +} + +// Redis 모킹 +jest.mock('redis', () => ({ + createClient: jest.fn(), +})); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +})); + +describe('RedisCache', () => { + let redisCache: RedisCache; + let mockClient: MockRedisClient; + let config: CacheConfig; + let mockCreateClient: jest.MockedFunction; + + beforeEach(() => { + // Redis 클라이언트 모킹 설정 + mockClient = { + connect: jest.fn(), + destroy: jest.fn(), + on: jest.fn(), + get: jest.fn(), + set: jest.fn(), + setEx: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + keys: jest.fn(), + scan: jest.fn(), + }; + + mockCreateClient = createClient as jest.MockedFunction; + mockCreateClient.mockReturnValue(mockClient as unknown as ReturnType); + + // 테스트용 설정 + config = { + host: 'localhost', + port: 6379, + password: 'test-password', + db: 0, + keyPrefix: 'test:cache:', + defaultTTL: 300, + }; + + redisCache = new RedisCache(config); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('설정값을 올바르게 초기화해야 한다', () => { + expect(mockCreateClient).toHaveBeenCalledWith({ + socket: { + host: 'localhost', + port: 6379, + }, + password: 'test-password', + database: 0, + }); + }); + + it('기본값으로 설정을 초기화해야 한다', () => { + const minimalConfig: CacheConfig = { + host: 'localhost', + port: 6379, + }; + + new RedisCache(minimalConfig); + + // 생성자에서 이벤트 핸들러 설정이 호출되는지 확인 + expect(mockClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('connect', expect.any(Function)); + expect(mockClient.on).toHaveBeenCalledWith('destroy', expect.any(Function)); + }); + }); + + describe('connect', () => { + it('연결되지 않은 상태에서 연결에 성공해야 한다', async () => { + mockClient.connect.mockResolvedValue(undefined); + + await redisCache.connect(); + + expect(mockClient.connect).toHaveBeenCalledTimes(1); + expect(redisCache.isConnected()).toBe(true); + }); + + it('이미 연결된 상태에서는 재연결하지 않아야 한다', async () => { + // 먼저 연결 + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + + // 두 번째 연결 시도 + await redisCache.connect(); + + expect(mockClient.connect).toHaveBeenCalledTimes(1); + }); + + it('연결 실패 시 에러를 던져야 한다', async () => { + const connectionError = new Error('Connection failed'); + mockClient.connect.mockRejectedValue(connectionError); + + await expect(redisCache.connect()).rejects.toThrow('Connection failed'); + expect(redisCache.isConnected()).toBe(false); + }); + }); + + describe('destroy', () => { + beforeEach(async () => { + // 연결 상태로 만들기 + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('연결된 상태에서 연결 해제에 성공해야 한다', async () => { + mockClient.destroy.mockResolvedValue(undefined); + + await redisCache.destroy(); + + expect(mockClient.destroy).toHaveBeenCalledTimes(1); + expect(redisCache.isConnected()).toBe(false); + }); + + it('연결되지 않은 상태에서는 연결 해제하지 않아야 한다', async () => { + // 먼저 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + // 두 번째 연결 해제 시도 + await redisCache.destroy(); + + expect(mockClient.destroy).toHaveBeenCalledTimes(1); + }); + }); + + describe('get', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('존재하는 키의 값을 성공적으로 가져와야 한다', async () => { + const testData = { name: 'test', value: 123 }; + mockClient.get.mockResolvedValue(JSON.stringify(testData)); + + const result = await redisCache.get('test-key'); + + expect(mockClient.get).toHaveBeenCalledWith('test:cache:test-key'); + expect(result).toEqual(testData); + }); + + it('존재하지 않는 키에 대해 null을 반환해야 한다', async () => { + mockClient.get.mockResolvedValue(null); + + const result = await redisCache.get('non-existent-key'); + + expect(result).toBeNull(); + }); + + it('연결되지 않은 상태에서 null을 반환해야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + const result = await redisCache.get('test-key'); + + expect(result).toBeNull(); + expect(mockClient.get).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 null을 반환해야 한다', async () => { + mockClient.get.mockRejectedValue(new Error('Redis error')); + + const result = await redisCache.get('test-key'); + + expect(result).toBeNull(); + }); + + it('JSON 파싱 에러 발생 시 null을 반환해야 한다', async () => { + mockClient.get.mockResolvedValue('invalid json'); + + const result = await redisCache.get('test-key'); + + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('TTL을 지정하여 값을 성공적으로 저장해야 한다', async () => { + const testData = { name: 'test', value: 123 }; + mockClient.setEx.mockResolvedValue('OK'); + + await redisCache.set('test-key', testData, 600); + + expect(mockClient.setEx).toHaveBeenCalledWith('test:cache:test-key', 600, JSON.stringify(testData)); + }); + + it('TTL 없이 값을 성공적으로 저장해야 한다 (기본 TTL 사용)', async () => { + const testData = { name: 'test', value: 123 }; + mockClient.setEx.mockResolvedValue('OK'); + + await redisCache.set('test-key', testData); + + expect(mockClient.setEx).toHaveBeenCalledWith( + 'test:cache:test-key', + 300, // 기본 TTL + JSON.stringify(testData), + ); + }); + + it('TTL이 0인 경우 만료 시간 없이 저장해야 한다', async () => { + const testData = { name: 'test', value: 123 }; + mockClient.set.mockResolvedValue('OK'); + + await redisCache.set('test-key', testData, 0); + + expect(mockClient.set).toHaveBeenCalledWith('test:cache:test-key', JSON.stringify(testData)); + }); + + it('연결되지 않은 상태에서는 저장하지 않아야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + await redisCache.set('test-key', { test: 'data' }); + + expect(mockClient.setEx).not.toHaveBeenCalled(); + expect(mockClient.set).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 조용히 실패해야 한다', async () => { + mockClient.setEx.mockRejectedValue(new Error('Redis error')); + + await expect(redisCache.set('test-key', { test: 'data' })).resolves.not.toThrow(); + }); + }); + + describe('delete', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('존재하는 키를 성공적으로 삭제해야 한다', async () => { + mockClient.del.mockResolvedValue(1); + + const result = await redisCache.delete('test-key'); + + expect(mockClient.del).toHaveBeenCalledWith('test:cache:test-key'); + expect(result).toBe(true); + }); + + it('존재하지 않는 키 삭제 시 false를 반환해야 한다', async () => { + mockClient.del.mockResolvedValue(0); + + const result = await redisCache.delete('non-existent-key'); + + expect(result).toBe(false); + }); + + it('연결되지 않은 상태에서 false를 반환해야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + const result = await redisCache.delete('test-key'); + + expect(result).toBe(false); + expect(mockClient.del).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 false를 반환해야 한다', async () => { + mockClient.del.mockRejectedValue(new Error('Redis error')); + + const result = await redisCache.delete('test-key'); + + expect(result).toBe(false); + }); + }); + + describe('exists', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('존재하는 키에 대해 true를 반환해야 한다', async () => { + mockClient.exists.mockResolvedValue(1); + + const result = await redisCache.exists('test-key'); + + expect(mockClient.exists).toHaveBeenCalledWith('test:cache:test-key'); + expect(result).toBe(true); + }); + + it('존재하지 않는 키에 대해 false를 반환해야 한다', async () => { + mockClient.exists.mockResolvedValue(0); + + const result = await redisCache.exists('non-existent-key'); + + expect(result).toBe(false); + }); + + it('연결되지 않은 상태에서 false를 반환해야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + const result = await redisCache.exists('test-key'); + + expect(result).toBe(false); + expect(mockClient.exists).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 false를 반환해야 한다', async () => { + mockClient.exists.mockRejectedValue(new Error('Redis error')); + + const result = await redisCache.exists('test-key'); + + expect(result).toBe(false); + }); + }); + + describe('clear', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('패턴에 맞는 키들을 성공적으로 삭제해야 한다', async () => { + const matchingKeys = ['test:cache:key1', 'test:cache:key2']; + mockClient.scan + .mockResolvedValueOnce({ cursor: '10', keys: matchingKeys }) + .mockResolvedValueOnce({ cursor: '0', keys: [] }); + mockClient.del.mockResolvedValue(2); + + await redisCache.clear('user:*'); + + expect(mockClient.scan).toHaveBeenCalledWith('0', { + MATCH: 'test:cache:user:*', + COUNT: 100, + }); + expect(mockClient.del).toHaveBeenCalledWith(matchingKeys); + }); + + it('패턴 없이 모든 키를 삭제해야 한다', async () => { + const allKeys = ['test:cache:key1', 'test:cache:key2']; + mockClient.scan.mockResolvedValueOnce({ cursor: '0', keys: allKeys }); + mockClient.del.mockResolvedValue(2); + + await redisCache.clear(); + + expect(mockClient.scan).toHaveBeenCalledWith('0', { + MATCH: 'test:cache:*', + COUNT: 100, + }); + expect(mockClient.del).toHaveBeenCalledWith(allKeys); + }); + + it('매칭되는 키가 없는 경우 삭제하지 않아야 한다', async () => { + mockClient.scan.mockResolvedValue({ cursor: '0', keys: [] }); + + await redisCache.clear('non-existent:*'); + + expect(mockClient.scan).toHaveBeenCalledWith('0', { + MATCH: 'test:cache:non-existent:*', + COUNT: 100, + }); + expect(mockClient.del).not.toHaveBeenCalled(); + }); + + it('연결되지 않은 상태에서는 삭제하지 않아야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + await redisCache.clear('test:*'); + + expect(mockClient.scan).not.toHaveBeenCalled(); + expect(mockClient.del).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 조용히 실패해야 한다', async () => { + mockClient.scan.mockRejectedValue(new Error('Redis error')); + + await expect(redisCache.clear('test:*')).resolves.not.toThrow(); + }); + }); + + describe('size', () => { + beforeEach(async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + }); + + it('캐시 크기를 올바르게 반환해야 한다', async () => { + const keys1 = ['test:cache:key1', 'test:cache:key2']; + const keys2 = ['test:cache:key3']; + mockClient.scan + .mockResolvedValueOnce({ cursor: '10', keys: keys1 }) + .mockResolvedValueOnce({ cursor: '0', keys: keys2 }); + + const result = await redisCache.size(); + + expect(mockClient.scan).toHaveBeenCalledWith('0', { + MATCH: 'test:cache:*', + COUNT: 100, + }); + expect(result).toBe(3); + }); + + it('빈 캐시의 크기는 0이어야 한다', async () => { + mockClient.scan.mockResolvedValue({ cursor: '0', keys: [] }); + + const result = await redisCache.size(); + + expect(result).toBe(0); + }); + + it('연결되지 않은 상태에서 0을 반환해야 한다', async () => { + // 연결 해제 + mockClient.destroy.mockResolvedValue(undefined); + await redisCache.destroy(); + + const result = await redisCache.size(); + + expect(result).toBe(0); + expect(mockClient.scan).not.toHaveBeenCalled(); + }); + + it('Redis 에러 발생 시 0을 반환해야 한다', async () => { + mockClient.scan.mockRejectedValue(new Error('Redis error')); + + const result = await redisCache.size(); + + expect(result).toBe(0); + }); + }); + + describe('이벤트 핸들러', () => { + it('연결 이벤트 시 상태를 업데이트해야 한다', () => { + const connectCall = mockClient.on.mock.calls.find( + (call: [string, (...args: unknown[]) => void]) => call[0] === 'connect', + ); + const connectHandler = connectCall?.[1]; + + expect(connectHandler).toBeDefined(); + connectHandler?.(); + expect(redisCache.isConnected()).toBe(true); + }); + + it('에러 이벤트 시 상태를 업데이트해야 한다', () => { + // 먼저 연결 상태로 만들기 + const connectCall = mockClient.on.mock.calls.find( + (call: [string, (...args: unknown[]) => void]) => call[0] === 'connect', + ); + const connectHandler = connectCall?.[1]; + expect(connectHandler).toBeDefined(); + connectHandler?.(); + + const errorCall = mockClient.on.mock.calls.find( + (call: [string, (...args: unknown[]) => void]) => call[0] === 'error', + ); + const errorHandler = errorCall?.[1]; + + expect(errorHandler).toBeDefined(); + errorHandler?.(new Error('Test error')); + expect(redisCache.isConnected()).toBe(false); + }); + + it('연결 해제 이벤트 시 상태를 업데이트해야 한다', () => { + // 먼저 연결 상태로 만들기 + const connectCall = mockClient.on.mock.calls.find( + (call: [string, (...args: unknown[]) => void]) => call[0] === 'connect', + ); + const connectHandler = connectCall?.[1]; + expect(connectHandler).toBeDefined(); + connectHandler?.(); + + const destroyCall = mockClient.on.mock.calls.find( + (call: [string, (...args: unknown[]) => void]) => call[0] === 'destroy', + ); + const destroyHandler = destroyCall?.[1]; + + expect(destroyHandler).toBeDefined(); + destroyHandler?.(); + expect(redisCache.isConnected()).toBe(false); + }); + }); + + describe('private getFullKey', () => { + it('키에 접두사를 올바르게 추가해야 한다', async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + + mockClient.get.mockResolvedValue(null); + + await redisCache.get('test-key'); + + expect(mockClient.get).toHaveBeenCalledWith('test:cache:test-key'); + }); + + it('빈 키에도 접두사를 추가해야 한다', async () => { + mockClient.connect.mockResolvedValue(undefined); + await redisCache.connect(); + + mockClient.get.mockResolvedValue(null); + + await redisCache.get(''); + + expect(mockClient.get).toHaveBeenCalledWith('test:cache:'); + }); + }); +}); diff --git a/src/modules/cache/cache.exception.ts b/src/modules/cache/cache.exception.ts new file mode 100644 index 0000000..82ae8b4 --- /dev/null +++ b/src/modules/cache/cache.exception.ts @@ -0,0 +1,20 @@ +export class CacheError extends Error { + constructor(message: string, public readonly code?: string) { + super(message); + this.name = 'CacheError'; + } +} + +export class CacheConnectionError extends CacheError { + constructor(message: string) { + super(message, 'CONNECTION_ERROR'); + this.name = 'CacheConnectionError'; + } +} + +export class CacheOperationError extends CacheError { + constructor(message: string, operation: string) { + super(message, `OPERATION_ERROR_${operation.toUpperCase()}`); + this.name = 'CacheOperationError'; + } +} \ No newline at end of file diff --git a/src/modules/cache/cache.type.ts b/src/modules/cache/cache.type.ts new file mode 100644 index 0000000..273c910 --- /dev/null +++ b/src/modules/cache/cache.type.ts @@ -0,0 +1,80 @@ +/** + * 캐시 설정 옵션입니다. + * + * @property host 캐시 서버의 호스트명 또는 IP 주소 + * @property port 캐시 서버의 포트 번호 + * @property [password] 캐시 서버 인증 비밀번호(선택) + * @property [db] 사용할 데이터베이스 인덱스(선택) + * @property [keyPrefix] 모든 키에 붙일 접두사(선택) + * @property [defaultTTL] 기본 만료 시간(초, 선택) + */ +export interface CacheConfig { + host: string; + port: number; + password?: string; + db?: number; + keyPrefix?: string; + defaultTTL?: number; +} + +/** + * 캐시 서비스 인터페이스입니다. + */ +export interface ICache { + /** + * 키로부터 값을 가져옵니다. + * @param key 값을 가져올 키 + * @returns 값을 반환하거나 없으면 null을 반환합니다. + */ + get(key: string): Promise; + + /** + * 값을 캐시에 저장합니다. + * @param key 저장할 키 + * @param value 저장할 값 + * @param ttlSeconds 값의 만료 시간(초, 선택) + */ + set(key: string, value: T, ttlSeconds?: number): Promise; + + /** + * 키에 해당하는 값을 삭제합니다. + * @param key 삭제할 키 + * @returns 삭제 성공 여부를 반환합니다. + */ + delete(key: string): Promise; + + /** + * 키가 존재하는지 확인합니다. + * @param key 확인할 키 + * @returns 존재하면 true, 아니면 false를 반환합니다. + */ + exists(key: string): Promise; + + /** + * 캐시를 비웁니다. 패턴이 있으면 해당 키만 비웁니다. + * @param pattern 비울 키의 패턴(선택) + */ + clear(pattern?: string): Promise; + + /** + * 캐시에 저장된 항목 개수를 반환합니다. + * @returns 항목 개수 + */ + size(): Promise; + + /** + * 캐시 서버에 연결합니다. + */ + connect(): Promise; + + /** + * 캐시 서버와 연결을 끊습니다. + */ + destroy(): Promise; + + /** + * 캐시 서버와 연결되어 있는지 확인합니다. + * @returns 연결되어 있으면 true, 아니면 false + */ + isConnected(): boolean; +} \ No newline at end of file diff --git a/src/modules/cache/redis.cache.ts b/src/modules/cache/redis.cache.ts new file mode 100644 index 0000000..7694572 --- /dev/null +++ b/src/modules/cache/redis.cache.ts @@ -0,0 +1,238 @@ +import { createClient, RedisClientType } from 'redis'; + +import logger from '@/configs/logger.config'; +import { ICache, CacheConfig } from './cache.type'; + +export class RedisCache implements ICache { + private client: RedisClientType; + private connected: boolean = false; + private keyPrefix: string; + private defaultTTL: number; + + constructor(config: CacheConfig) { + this.keyPrefix = config.keyPrefix || 'vd2:cache:'; + this.defaultTTL = config.defaultTTL || 300; + + this.client = createClient({ + socket: { + host: config.host, + port: config.port, + }, + password: config.password, + database: config.db || 0, + }); + + this.setupEventHandlers(); + } + + /** + * Redis 클라이언트의 이벤트 핸들러를 설정합니다. + * 에러, 연결, 연결 해제 시 상태를 변경하고 로그를 남깁니다. + * + * @private + */ + private setupEventHandlers(): void { + this.client.on('error', (err) => { + logger.error('Redis Client Error:', err); + this.connected = false; + }); + + this.client.on('connect', () => { + logger.info('Redis Client Connected'); + this.connected = true; + }); + + this.client.on('destroy', () => { + logger.warn('Redis Client Destroyed'); + this.connected = false; + }); + } + + /** + * 주어진 키에 keyPrefix를 접두사로 붙여 전체 Redis 키를 생성합니다. + * + * @param key - 접두사가 붙을 원본 키 문자열 + * @returns keyPrefix가 포함된 전체 Redis 키 문자열 + */ + private getFullKey(key: string): string { + return `${this.keyPrefix}${key}`; + } + + async connect(): Promise { + try { + if (!this.connected) { + await this.client.connect(); + this.connected = true; + logger.info('Redis cache connection established'); + } + } catch (error) { + logger.error('Failed to connect to Redis cache:', error); + throw error; + } + } + + async destroy(): Promise { + try { + if (this.connected) { + this.client.destroy(); + this.connected = false; + logger.info('Redis cache connection closed'); + } + } catch (error) { + logger.error('Failed to destroy from Redis cache:', error); + throw error; + } + } + + isConnected(): boolean { + return this.connected; + } + + async get(key: string): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache get'); + return null; + } + + const value = await this.client.get(this.getFullKey(key)); + if (!value) return null; + + // JSON.parse가 실패할 경우를 명시적으로 처리 + try { + return JSON.parse(value); + } catch (parseError) { + logger.error(`Failed to parse cached value for key ${key}:`, parseError); + // 손상된 캐시 데이터 삭제 + await this.delete(key); + return null; + } + } catch (error) { + logger.error(`Cache GET error for key ${key}:`, error); + return null; + } + } + + async set(key: string, value: T, ttlSeconds?: number): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache set'); + return; + } + + const fullKey = this.getFullKey(key); + const serializedValue = JSON.stringify(value); + const ttl = ttlSeconds ?? this.defaultTTL; + + if (ttl > 0) { + await this.client.setEx(fullKey, ttl, serializedValue); + } else { + await this.client.set(fullKey, serializedValue); + } + } catch (error) { + logger.error(`Cache SET error for key ${key}:`, error); + // 캐시 오류 시에도 애플리케이션은 계속 동작 + } + } + + async delete(key: string): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache delete'); + return false; + } + + const result = await this.client.del(this.getFullKey(key)); + return result > 0; + } catch (error) { + logger.error(`Cache DELETE error for key ${key}:`, error); + return false; + } + } + + async exists(key: string): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache exists'); + return false; + } + + const result = await this.client.exists(this.getFullKey(key)); + return result > 0; + } catch (error) { + logger.error(`Cache EXISTS error for key ${key}:`, error); + return false; + } + } + + async clear(pattern?: string, batchSize: number = 100): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache clear'); + return; + } + + const searchPattern = pattern ? `${this.keyPrefix}${pattern}` : `${this.keyPrefix}*`; + + let cursor = '0'; + let totalDeleted = 0; + + do { + const result = await this.client.scan(cursor, { + MATCH: searchPattern, + COUNT: batchSize, + }); + + cursor = result.cursor; + const keys = result.keys; + + if (keys.length > 0) { + await this.client.del(keys); + totalDeleted += keys.length; + } + + if (cursor !== '0') { + await new Promise((resolve) => setImmediate(resolve)); + } + } while (cursor !== '0'); + + if (totalDeleted > 0) { + logger.info(`Cache cleared: ${totalDeleted} keys deleted`); + } + } catch (error) { + logger.error(`Cache CLEAR error for pattern ${pattern}:`, error); + } + } + + async size(): Promise { + try { + if (!this.connected) { + logger.warn('Redis not connected, skipping cache size, return 0'); + return 0; + } + + let cursor = '0'; + let count = 0; + const batchSize = 100; + + do { + const result = await this.client.scan(cursor, { + MATCH: `${this.keyPrefix}*`, + COUNT: batchSize, + }); + + cursor = result.cursor; + count += result.keys.length; + + if (cursor !== '0') { + await new Promise((resolve) => setImmediate(resolve)); + } + } while (cursor !== '0'); + + return count; + } catch (error) { + logger.error('Cache SIZE error:', error); + return 0; + } + } +} diff --git a/src/modules/__test__/slack.notifier.test.ts b/src/modules/slack/__test__/slack.notifier.test.ts similarity index 100% rename from src/modules/__test__/slack.notifier.test.ts rename to src/modules/slack/__test__/slack.notifier.test.ts diff --git a/src/modules/__test__/aes.encryption.test.ts b/src/modules/token_encryption/__test__/aes.encryption.test.ts similarity index 100% rename from src/modules/__test__/aes.encryption.test.ts rename to src/modules/token_encryption/__test__/aes.encryption.test.ts diff --git a/src/modules/__test__/velog.api.test.ts b/src/modules/velog/__test__/velog.api.test.ts similarity index 100% rename from src/modules/__test__/velog.api.test.ts rename to src/modules/velog/__test__/velog.api.test.ts diff --git a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts index 6e177fb..9ec325d 100644 --- a/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts +++ b/src/repositories/__test__/integration/leaderboard.repo.integration.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-disabled-tests */ /** * 주의: 이 통합 테스트는 현재 시간에 의존적입니다. * getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로 @@ -20,7 +21,7 @@ jest.setTimeout(60000); // 각 케이스당 60초 타임아웃 설정 * 이 테스트 파일은 실제 데이터베이스와 연결하여 LeaderboardRepository의 모든 메서드를 * 실제 환경과 동일한 조건에서 테스트합니다. */ -describe('LeaderboardRepository 통합 테스트', () => { +describe.skip('LeaderboardRepository 통합 테스트', () => { let testPool: Pool; let repo: LeaderboardRepository; diff --git a/src/repositories/user.repository.ts b/src/repositories/user.repository.ts index d6629dd..ebeaee2 100644 --- a/src/repositories/user.repository.ts +++ b/src/repositories/user.repository.ts @@ -5,7 +5,7 @@ import { QRLoginToken } from '@/types/models/QRLoginToken.type'; import { DBError } from '@/exception'; export class UserRepository { - constructor(private readonly pool: Pool) { } + constructor(private readonly pool: Pool) {} async findByUserId(id: number): Promise { try { @@ -42,7 +42,14 @@ export class UserRepository { } } - async updateTokens(uuid: string, email: string | null, username: string | null, thumbnail: string | null, encryptedAccessToken: string, encryptedRefreshToken: string): Promise { + async updateTokens( + uuid: string, + email: string | null, + username: string | null, + thumbnail: string | null, + encryptedAccessToken: string, + encryptedRefreshToken: string, + ): Promise { try { const query = ` UPDATE "users_user" diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts index f48d7fa..5a5bcd6 100644 --- a/src/routes/webhook.router.ts +++ b/src/routes/webhook.router.ts @@ -50,4 +50,4 @@ const webhookController = new WebhookController(); */ router.post('/webhook/sentry', authMiddleware.verifySignature, webhookController.handleSentryWebhook); -export default router; \ No newline at end of file +export default router; diff --git a/src/services/post.service.ts b/src/services/post.service.ts index 8b7350d..8951acf 100644 --- a/src/services/post.service.ts +++ b/src/services/post.service.ts @@ -1,6 +1,7 @@ import logger from '@/configs/logger.config'; import { PostRepository } from '@/repositories/post.repository'; import { RawPostType } from '@/types'; +// import { cache } from '@/configs/cache.config'; import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; export class PostService { @@ -8,6 +9,12 @@ export class PostService { async getAllposts(userId: number, cursor?: string, sort: string = '', isAsc?: boolean, limit: number = 15) { try { + // const cacheKey = `posts:user:${userId}:${cursor || 'first'}:${sort}:${isAsc}:${limit}`; + // const cachedResult = await cache.get(cacheKey); + // if (cachedResult) { + // return cachedResult; + // } + let result = null; if (sort === 'viewGrowth') { result = await this.postRepo.findPostsByUserIdWithGrowthMetrics(userId, cursor, isAsc, limit); @@ -27,10 +34,16 @@ export class PostService { releasedAt: post.post_released_at, })); - return { + const results = { posts: transformedPosts, nextCursor: result.nextCursor, }; + + // // 결과가 빈 값이 아니라면 캐시에 저장 (5분 TTL) + // if (results.posts.length > 0) { + // await cache.set(cacheKey, results, 300); + // } + return results; } catch (error) { logger.error('PostService getAllposts error : ', error); throw error; @@ -39,6 +52,12 @@ export class PostService { async getAllPostsStatistics(userId: number) { try { + // const cacheKey = `posts:stats:${userId}`; + // const cachedResult = await cache.get(cacheKey); + // if (cachedResult) { + // return cachedResult; + // } + const postsStatistics = await this.postRepo.getYesterdayAndTodayViewLikeStats(userId); const transformedStatistics = { @@ -49,6 +68,11 @@ export class PostService { lastUpdatedDate: postsStatistics.last_updated_date, }; + // // 결과가 빈 값이 아니라면 캐시에 저장 (5분 TTL) + // if (transformedStatistics.totalViews > 0) { + // await cache.set(cacheKey, transformedStatistics, 300); + // } + return transformedStatistics; } catch (error) { logger.error('PostService getAllPostsStatistics error : ', error); diff --git a/src/services/user.service.ts b/src/services/user.service.ts index aef36bb..66acf84 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -10,7 +10,7 @@ import { generateRandomToken } from '@/utils/generateRandomToken.util'; import { VelogUserCurrentResponse } from '@/modules/velog/velog.type'; export class UserService { - constructor(private userRepo: UserRepository) { } + constructor(private userRepo: UserRepository) {} private encryptTokens(groupId: number, accessToken: string, refreshToken: string) { const key = getKeyByGroup(groupId); @@ -57,7 +57,7 @@ export class UserService { ): Promise { // velog response 에서 주는 응답 혼용 방지를 위한 변경 id -> uuid const { id: uuid, email = null, username, profile } = userData; - const thumbnail = profile?.thumbnail || null // undefined 방어 + const thumbnail = profile?.thumbnail || null; // undefined 방어 try { let user = await this.userRepo.findByUserVelogUUID(uuid); @@ -133,7 +133,14 @@ export class UserService { } async updateUserTokens(userData: UserWithTokenDto) { - return await this.userRepo.updateTokens(userData.uuid, userData.email, userData.username, userData.thumbnail, userData.accessToken, userData.refreshToken); + return await this.userRepo.updateTokens( + userData.uuid, + userData.email, + userData.username, + userData.thumbnail, + userData.accessToken, + userData.refreshToken, + ); } async createUserQRToken(userId: number, ip: string, userAgent: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index ba888fc..068ba34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,14 +39,8 @@ export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.t export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; // Sentry 관련 -export type { - SentryIssueStatus, -} from '@/types/models/Sentry.type'; -export type { - SentryProject, - SentryIssue, - SentryWebhookData, -} from '@/types/models/Sentry.type'; +export type { SentryIssueStatus } from '@/types/models/Sentry.type'; +export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type'; // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type';