Skip to content

[25.07.19 / TASK-212] Feature - cache layer, 모듈 추가 #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jul 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
41f0f84
feature: cache module 초기 세팅 및 추가
Nuung Jul 19, 2025
fbfb886
modify: 디렉토리 세팅 변경
Nuung Jul 19, 2025
2b4e3fd
feature: redis cache class 추가와 jsdocs 추가
Nuung Jul 19, 2025
0e91a61
feature: module 하위 test 디렉토리 분리, cache index 추가
Nuung Jul 19, 2025
ea8337f
feature: cache instance 랑 index 대신 src config 활용
Nuung Jul 19, 2025
f29e3cd
feature: 추후 Ops 를 위한 헬스 체크 세팅, app.ts 리뉴얼
Nuung Jul 19, 2025
40d531a
modify: 린팅
Nuung Jul 19, 2025
58ddfa6
modify: app import 수정
Nuung Jul 19, 2025
25495ca
feature: cache layer, redis 에 대한 unit test 추가
Nuung Jul 19, 2025
e40f352
modify: post service 계층 캐시 추가 예시
Nuung Jul 19, 2025
5c51bdf
modify: 잔잔한 버그 수정과 리더보드 통합 E2E 테스트는 잠깐 skip 처리
Nuung Jul 19, 2025
26d27fd
modify: redis cache JSON.parse 이슈 세부 처리와 예상치 못한 오류에 대한 grace-full exit
Nuung Jul 19, 2025
7271f33
hotfix
Nuung Jul 19, 2025
672694f
hotfix linting
Nuung Jul 19, 2025
9b1029f
modify: redis 구현채, SCAN 으로 블록킹 완화
Nuung Jul 20, 2025
905beff
modify: unhandledRejection 의 gracefulShutdown 메시지 수정
Nuung Jul 20, 2025
a208007
modify: index 에서 gracefulShutdown 발생시 cache connection 끊기 추가, 그에 따라 모…
Nuung Jul 20, 2025
2937659
modify: disconn 대신 destroy, log warning 강화
Nuung Jul 25, 2025
d0af9d6
modify: destroy 도입에 따른 test 와 config 값 변경
Nuung Jul 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 빌드된 프로젝트 시작
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default typescriptEslint.config(
},
{
selector: 'variable',
format: ['camelCase'],
format: ['camelCase', 'UPPER_CASE'],
leadingUnderscore: 'allow',
},
{
Expand Down
26 changes: 19 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -65,4 +77,4 @@
"typescript-eslint": "^8.15.0",
"typescript-transform-paths": "^3.5.3"
}
}
}
69 changes: 69 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 63 additions & 15 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -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;
62 changes: 62 additions & 0 deletions src/configs/cache.config.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<boolean> => {
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;
}
};
3 changes: 2 additions & 1 deletion src/configs/db.config.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Loading
Loading