라벨명 표시기능

This commit is contained in:
kjs
2025-09-08 14:20:01 +09:00
parent 1eeda775ef
commit 2d07041110
20 changed files with 1415 additions and 497 deletions
+38 -6
View File
@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.7.1",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -3609,9 +3610,19 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -4189,7 +4200,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -4442,7 +4452,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -4733,7 +4742,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5305,11 +5313,30 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -5645,7 +5672,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -7821,6 +7847,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+1
View File
@@ -28,6 +28,7 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.7.1",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
+183
View File
@@ -0,0 +1,183 @@
/**
* 테이블 타입관리 성능 테스트 스크립트
* 최적화 전후 성능 비교용
*/
const axios = require("axios");
const BASE_URL = "http://localhost:3001/api";
const TEST_TABLE = "user_info"; // 테스트할 테이블명
// 성능 측정 함수
async function measurePerformance(name, fn) {
const start = Date.now();
try {
const result = await fn();
const end = Date.now();
const duration = end - start;
console.log(`${name}: ${duration}ms`);
return { success: true, duration, result };
} catch (error) {
const end = Date.now();
const duration = end - start;
console.log(`${name}: ${duration}ms (실패: ${error.message})`);
return { success: false, duration, error: error.message };
}
}
// 테스트 함수들
const tests = {
// 1. 테이블 목록 조회 성능
async testTableList() {
return await axios.get(`${BASE_URL}/table-management/tables`);
},
// 2. 컬럼 목록 조회 성능 (첫 페이지)
async testColumnListFirstPage() {
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
},
// 3. 컬럼 목록 조회 성능 (큰 페이지)
async testColumnListLargePage() {
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=200`
);
},
// 4. 캐시 효과 테스트 (동일한 요청 반복)
async testCacheEffect() {
// 첫 번째 요청 (캐시 미스)
await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
// 두 번째 요청 (캐시 히트)
return await axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=1&size=50`
);
},
// 5. 동시 요청 처리 성능
async testConcurrentRequests() {
const requests = Array(10)
.fill()
.map((_, i) =>
axios.get(
`${BASE_URL}/table-management/tables/${TEST_TABLE}/columns?page=${i + 1}&size=20`
)
);
return await Promise.all(requests);
},
};
// 메인 테스트 실행
async function runPerformanceTests() {
console.log("🚀 테이블 타입관리 성능 테스트 시작\n");
console.log(`📊 테스트 대상: ${BASE_URL}`);
console.log(`📋 테스트 테이블: ${TEST_TABLE}\n`);
const results = {};
// 각 테스트 실행
for (const [testName, testFn] of Object.entries(tests)) {
console.log(`\n--- ${testName} ---`);
// 각 테스트를 3번 실행하여 평균 계산
const runs = [];
for (let i = 0; i < 3; i++) {
const result = await measurePerformance(`실행 ${i + 1}`, testFn);
runs.push(result);
// 테스트 간 간격
await new Promise((resolve) => setTimeout(resolve, 100));
}
// 성공한 실행들의 평균 시간 계산
const successfulRuns = runs.filter((r) => r.success);
if (successfulRuns.length > 0) {
const avgDuration =
successfulRuns.reduce((sum, r) => sum + r.duration, 0) /
successfulRuns.length;
const minDuration = Math.min(...successfulRuns.map((r) => r.duration));
const maxDuration = Math.max(...successfulRuns.map((r) => r.duration));
results[testName] = {
average: Math.round(avgDuration),
min: minDuration,
max: maxDuration,
successRate: (successfulRuns.length / runs.length) * 100,
};
console.log(
`📈 평균: ${Math.round(avgDuration)}ms, 최소: ${minDuration}ms, 최대: ${maxDuration}ms`
);
} else {
results[testName] = { error: "모든 테스트 실패" };
console.log("❌ 모든 테스트 실패");
}
}
// 결과 요약
console.log("\n" + "=".repeat(50));
console.log("📊 성능 테스트 결과 요약");
console.log("=".repeat(50));
for (const [testName, result] of Object.entries(results)) {
if (result.error) {
console.log(`${testName}: ${result.error}`);
} else {
console.log(
`${testName}: ${result.average}ms (${result.min}-${result.max}ms, 성공률: ${result.successRate}%)`
);
}
}
// 성능 기준 평가
console.log("\n" + "=".repeat(50));
console.log("🎯 성능 기준 평가");
console.log("=".repeat(50));
const benchmarks = {
testTableList: { good: 200, acceptable: 500 },
testColumnListFirstPage: { good: 300, acceptable: 800 },
testColumnListLargePage: { good: 500, acceptable: 1200 },
testCacheEffect: { good: 50, acceptable: 150 },
testConcurrentRequests: { good: 1000, acceptable: 3000 },
};
for (const [testName, result] of Object.entries(results)) {
if (result.error) continue;
const benchmark = benchmarks[testName];
if (!benchmark) continue;
let status = "🔴 느림";
if (result.average <= benchmark.good) {
status = "🟢 우수";
} else if (result.average <= benchmark.acceptable) {
status = "🟡 양호";
}
console.log(`${status} ${testName}: ${result.average}ms`);
}
console.log("\n✨ 성능 테스트 완료!");
}
// 에러 핸들링
process.on("unhandledRejection", (error) => {
console.error("❌ 처리되지 않은 에러:", error.message);
process.exit(1);
});
// 테스트 실행
if (require.main === module) {
runPerformanceTests().catch(console.error);
}
module.exports = { runPerformanceTests, measurePerformance };
@@ -6,8 +6,22 @@ import { AuthenticatedRequest } from "../types/auth";
export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
try {
const { companyCode } = req.user as any;
const screens = await screenManagementService.getScreens(companyCode);
res.json({ success: true, data: screens });
const { page = 1, size = 20, searchTerm } = req.query;
const result = await screenManagementService.getScreensByCompany(
companyCode,
parseInt(page as string),
parseInt(size as string)
);
res.json({
success: true,
data: result.data,
total: result.pagination.total,
page: result.pagination.page,
size: result.pagination.size,
totalPages: result.pagination.totalPages,
});
} catch (error) {
console.error("화면 목록 조회 실패:", error);
res
@@ -60,7 +60,11 @@ export async function getColumnList(
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 컬럼 정보 조회 시작: ${tableName} ===`);
const { page = 1, size = 50 } = req.query;
logger.info(
`=== 컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}) ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
@@ -76,14 +80,20 @@ export async function getColumnList(
}
const tableManagementService = new TableManagementService();
const columnList = await tableManagementService.getColumnList(tableName);
const result = await tableManagementService.getColumnList(
tableName,
parseInt(page as string),
parseInt(size as string)
);
logger.info(`컬럼 정보 조회 결과: ${tableName}, ${columnList.length}`);
logger.info(
`컬럼 정보 조회 결과: ${tableName}, ${result.columns.length}/${result.total}개 (${result.page}/${result.totalPages} 페이지)`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
const response: ApiResponse<typeof result> = {
success: true,
message: "컬럼 목록을 성공적으로 조회했습니다.",
data: columnList,
data: result,
};
res.status(200).json(response);
@@ -377,6 +387,65 @@ export async function getColumnLabels(
}
}
/**
* 테이블 라벨 설정
*/
export async function updateTableLabel(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { displayName, description } = req.body;
logger.info(`=== 테이블 라벨 설정 시작: ${tableName} ===`);
logger.info(`표시명: ${displayName}, 설명: ${description}`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateTableLabel(
tableName,
displayName,
description
);
logger.info(`테이블 라벨 설정 완료: ${tableName}`);
const response: ApiResponse<null> = {
success: true,
message: "테이블 라벨이 성공적으로 설정되었습니다.",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 라벨 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 라벨 설정 중 오류가 발생했습니다.",
error: {
code: "TABLE_LABEL_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 웹 타입 설정
*/
@@ -8,6 +8,7 @@ import {
getTableLabels,
getColumnLabels,
updateColumnWebType,
updateTableLabel,
getTableData,
addTableData,
editTableData,
@@ -31,6 +32,12 @@ router.get("/tables", getTableList);
*/
router.get("/tables/:tableName/columns", getColumnList);
/**
* 테이블 라벨 설정
* PUT /api/table-management/tables/:tableName/label
*/
router.put("/tables/:tableName/label", updateTableLabel);
/**
* 개별 컬럼 설정 업데이트
* POST /api/table-management/tables/:tableName/columns/:columnName/settings
@@ -105,8 +105,45 @@ export class ScreenManagementService {
prisma.screen_definitions.count({ where: whereClause }),
]);
// 테이블 라벨 정보를 한 번에 조회
const tableNames = [
...new Set(screens.map((s) => s.table_name).filter(Boolean)),
];
let tableLabelMap = new Map<string, string>();
if (tableNames.length > 0) {
try {
const tableLabels = await prisma.table_labels.findMany({
where: { table_name: { in: tableNames } },
select: { table_name: true, table_label: true },
});
tableLabelMap = new Map(
tableLabels.map((tl) => [
tl.table_name,
tl.table_label || tl.table_name,
])
);
// 테스트: company_mng 라벨 직접 확인
if (tableLabelMap.has("company_mng")) {
console.log(
"✅ company_mng 라벨 찾음:",
tableLabelMap.get("company_mng")
);
} else {
console.log("❌ company_mng 라벨 없음");
}
} catch (error) {
console.error("테이블 라벨 조회 오류:", error);
}
}
return {
data: screens.map((screen) => this.mapToScreenDefinition(screen)),
data: screens.map((screen) =>
this.mapToScreenDefinition(screen, tableLabelMap)
),
pagination: {
page,
size,
@@ -404,6 +441,8 @@ export class ScreenManagementService {
}
// 메뉴 할당 확인
// 메뉴에 할당된 화면인지 확인 (임시 주석 처리)
/*
const menuAssignments = await prisma.screen_menu_assignments.findMany({
where: {
screen_id: screenId,
@@ -425,6 +464,7 @@ export class ScreenManagementService {
referenceType: "menu_assignment",
});
}
*/
return {
hasDependencies: dependencies.length > 0,
@@ -666,9 +706,22 @@ export class ScreenManagementService {
prisma.screen_definitions.count({ where: whereClause }),
]);
// 테이블 라벨 정보를 한 번에 조회
const tableNames = [
...new Set(screens.map((s) => s.table_name).filter(Boolean)),
];
const tableLabels = await prisma.table_labels.findMany({
where: { table_name: { in: tableNames } },
select: { table_name: true, table_label: true },
});
const tableLabelMap = new Map(
tableLabels.map((tl) => [tl.table_name, tl.table_label || tl.table_name])
);
return {
data: screens.map((screen) => ({
...this.mapToScreenDefinition(screen),
...this.mapToScreenDefinition(screen, tableLabelMap),
deletedDate: screen.deleted_date || undefined,
deletedBy: screen.deleted_by || undefined,
deleteReason: screen.delete_reason || undefined,
@@ -1528,12 +1581,18 @@ export class ScreenManagementService {
// 유틸리티 메서드
// ========================================
private mapToScreenDefinition(data: any): ScreenDefinition {
private mapToScreenDefinition(
data: any,
tableLabelMap?: Map<string, string>
): ScreenDefinition {
const tableLabel = tableLabelMap?.get(data.table_name) || data.table_name;
return {
screenId: data.screen_id,
screenName: data.screen_name,
screenCode: data.screen_code,
tableName: data.table_name,
tableLabel: tableLabel, // 라벨이 있으면 라벨, 없으면 테이블명
companyCode: data.company_code,
description: data.description,
isActive: data.is_active,
@@ -1,5 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
import {
TableInfo,
ColumnTypeInfo,
@@ -21,6 +22,13 @@ export class TableManagementService {
try {
logger.info("테이블 목록 조회 시작");
// 캐시에서 먼저 확인
const cachedTables = cache.get<TableInfo[]>(CacheKeys.TABLE_LIST);
if (cachedTables) {
logger.info(`테이블 목록 캐시에서 조회: ${cachedTables.length}`);
return cachedTables;
}
// information_schema는 여전히 $queryRaw 사용
const rawTables = await prisma.$queryRaw<any[]>`
SELECT
@@ -44,6 +52,9 @@ export class TableManagementService {
columnCount: Number(table.columnCount), // BigInt → Number 변환
}));
// 캐시에 저장 (10분 TTL)
cache.set(CacheKeys.TABLE_LIST, tables, 10 * 60 * 1000);
logger.info(`테이블 목록 조회 완료: ${tables.length}`);
return tables;
} catch (error) {
@@ -55,14 +66,59 @@ export class TableManagementService {
}
/**
* 테이블 컬럼 정보 조회
* 테이블 컬럼 정보 조회 (페이지네이션 지원)
* 메타데이터 조회는 Prisma로 변경 불가
*/
async getColumnList(tableName: string): Promise<ColumnTypeInfo[]> {
async getColumnList(
tableName: string,
page: number = 1,
size: number = 50
): Promise<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
logger.info(`컬럼 정보 조회 시작: ${tableName}`);
logger.info(
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size})`
);
// information_schema는 여전히 $queryRaw 사용
// 캐시 키 생성
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size);
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 캐시에서 먼저 확인
const cachedResult = cache.get<{
columns: ColumnTypeInfo[];
total: number;
page: number;
size: number;
totalPages: number;
}>(cacheKey);
if (cachedResult) {
logger.info(
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}`
);
return cachedResult;
}
// 전체 컬럼 수 조회 (캐시 확인)
let total = cache.get<number>(countCacheKey);
if (!total) {
const totalResult = await prisma.$queryRaw<[{ count: bigint }]>`
SELECT COUNT(*) as count
FROM information_schema.columns c
WHERE c.table_name = ${tableName}
`;
total = Number(totalResult[0].count);
// 컬럼 수는 자주 변하지 않으므로 30분 캐시
cache.set(countCacheKey, total, 30 * 60 * 1000);
}
// 페이지네이션 적용한 컬럼 조회
const offset = (page - 1) * size;
const rawColumns = await prisma.$queryRaw<any[]>`
SELECT
c.column_name as "columnName",
@@ -87,6 +143,7 @@ export class TableManagementService {
LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
WHERE c.table_name = ${tableName}
ORDER BY c.ordinal_position
LIMIT ${size} OFFSET ${offset}
`;
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
@@ -100,8 +157,23 @@ export class TableManagementService {
displayOrder: column.displayOrder ? Number(column.displayOrder) : null,
}));
logger.info(`컬럼 정보 조회 완료: ${tableName}, ${columns.length}`);
return columns;
const totalPages = Math.ceil(total / size);
const result = {
columns,
total,
page,
size,
totalPages,
};
// 캐시에 저장 (5분 TTL)
cache.set(cacheKey, result, 5 * 60 * 1000);
logger.info(
`컬럼 정보 조회 완료: ${tableName}, ${columns.length}/${total}개 (${page}/${totalPages} 페이지)`
);
return result;
} catch (error) {
logger.error(`컬럼 정보 조회 중 오류 발생: ${tableName}`, error);
throw new Error(
@@ -137,6 +209,40 @@ export class TableManagementService {
}
}
/**
* 테이블 라벨 업데이트
*/
async updateTableLabel(
tableName: string,
displayName: string,
description?: string
): Promise<void> {
try {
logger.info(`테이블 라벨 업데이트 시작: ${tableName}`);
// table_labels 테이블에 UPSERT
await prisma.$executeRaw`
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
VALUES (${tableName}, ${displayName}, ${description || ""}, NOW(), NOW())
ON CONFLICT (table_name)
DO UPDATE SET
table_label = EXCLUDED.table_label,
description = EXCLUDED.description,
updated_date = NOW()
`;
// 캐시 무효화
cache.delete(CacheKeys.TABLE_LIST);
logger.info(`테이블 라벨 업데이트 완료: ${tableName}`);
} catch (error) {
logger.error("테이블 라벨 업데이트 중 오류 발생:", error);
throw new Error(
`테이블 라벨 업데이트 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 컬럼 설정 업데이트 (UPSERT 방식)
* Prisma ORM으로 변경
+1
View File
@@ -151,6 +151,7 @@ export interface ScreenDefinition {
screenName: string;
screenCode: string;
tableName: string;
tableLabel?: string; // 테이블 라벨 (한글명)
companyCode: string;
description?: string;
isActive: string;
+143
View File
@@ -0,0 +1,143 @@
/**
* 간단한 메모리 캐시 구현
* 테이블 타입관리 성능 최적화용
*/
interface CacheItem<T> {
data: T;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
class MemoryCache {
private cache = new Map<string, CacheItem<any>>();
private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5분
/**
* 캐시에 데이터 저장
*/
set<T>(key: string, data: T, ttl: number = this.DEFAULT_TTL): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
});
}
/**
* 캐시에서 데이터 조회
*/
get<T>(key: string): T | null {
const item = this.cache.get(key);
if (!item) {
return null;
}
// TTL 체크
if (Date.now() - item.timestamp > item.ttl) {
this.cache.delete(key);
return null;
}
return item.data as T;
}
/**
* 캐시에서 데이터 삭제
*/
delete(key: string): boolean {
return this.cache.delete(key);
}
/**
* 패턴으로 캐시 삭제 (테이블 관련 캐시 일괄 삭제용)
*/
deleteByPattern(pattern: string): number {
let deletedCount = 0;
const regex = new RegExp(pattern);
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
deletedCount++;
}
}
return deletedCount;
}
/**
* 만료된 캐시 정리
*/
cleanup(): number {
let cleanedCount = 0;
const now = Date.now();
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp > item.ttl) {
this.cache.delete(key);
cleanedCount++;
}
}
return cleanedCount;
}
/**
* 캐시 통계
*/
getStats(): {
totalKeys: number;
expiredKeys: number;
memoryUsage: string;
} {
const now = Date.now();
let expiredKeys = 0;
for (const item of this.cache.values()) {
if (now - item.timestamp > item.ttl) {
expiredKeys++;
}
}
return {
totalKeys: this.cache.size,
expiredKeys,
memoryUsage: `${Math.round(JSON.stringify([...this.cache.entries()]).length / 1024)} KB`,
};
}
/**
* 전체 캐시 초기화
*/
clear(): void {
this.cache.clear();
}
}
// 싱글톤 인스턴스
export const cache = new MemoryCache();
// 캐시 키 생성 헬퍼
export const CacheKeys = {
TABLE_LIST: "table_list",
TABLE_COLUMNS: (tableName: string, page: number, size: number) =>
`table_columns:${tableName}:${page}:${size}`,
TABLE_COLUMN_COUNT: (tableName: string) => `table_column_count:${tableName}`,
WEB_TYPE_OPTIONS: "web_type_options",
COMMON_CODES: (category: string) => `common_codes:${category}`,
} as const;
// 자동 정리 스케줄러 (10분마다)
setInterval(
() => {
const cleaned = cache.cleanup();
if (cleaned > 0) {
console.log(`[Cache] 만료된 캐시 ${cleaned}개 정리됨`);
}
},
10 * 60 * 1000
);
export default cache;