2025-10-01 11:48:55 +09:00
|
|
|
# 📋 Phase 3.11: DDLAuditLogger Raw Query 전환 계획
|
|
|
|
|
|
|
|
|
|
## 📋 개요
|
|
|
|
|
|
|
|
|
|
DDLAuditLogger는 **8개의 Prisma 호출**이 있으며, DDL 실행 감사 로그 관리를 담당하는 서비스입니다.
|
|
|
|
|
|
|
|
|
|
### 📊 기본 정보
|
|
|
|
|
|
2025-10-01 12:01:04 +09:00
|
|
|
| 항목 | 내용 |
|
|
|
|
|
| --------------- | --------------------------------------------- |
|
|
|
|
|
| 파일 위치 | `backend-node/src/services/ddlAuditLogger.ts` |
|
|
|
|
|
| 파일 크기 | 350 라인 |
|
|
|
|
|
| Prisma 호출 | 0개 (전환 완료) |
|
|
|
|
|
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
|
|
|
|
| 복잡도 | 중간 (통계 쿼리, $executeRaw) |
|
|
|
|
|
| 우선순위 | 🟡 중간 (Phase 3.11) |
|
|
|
|
|
| **상태** | ✅ **완료** |
|
2025-10-01 11:48:55 +09:00
|
|
|
|
|
|
|
|
### 🎯 전환 목표
|
|
|
|
|
|
|
|
|
|
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
|
|
|
|
- ⏳ DDL 감사 로그 기능 정상 동작
|
|
|
|
|
- ⏳ 통계 쿼리 전환 (GROUP BY, COUNT, ORDER BY)
|
|
|
|
|
- ⏳ $executeRaw → query 전환
|
|
|
|
|
- ⏳ $queryRawUnsafe → query 전환
|
|
|
|
|
- ⏳ 동적 WHERE 조건 생성
|
|
|
|
|
- ⏳ TypeScript 컴파일 성공
|
|
|
|
|
- ⏳ **Prisma import 완전 제거**
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🔍 Prisma 사용 현황 분석
|
|
|
|
|
|
|
|
|
|
### 주요 Prisma 호출 (8개)
|
|
|
|
|
|
|
|
|
|
#### 1. **logDDLStart()** - DDL 시작 로그 (INSERT)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Line 27
|
|
|
|
|
const logEntry = await prisma.$executeRaw`
|
|
|
|
|
INSERT INTO ddl_audit_logs (
|
|
|
|
|
execution_id, ddl_type, table_name, status,
|
|
|
|
|
executed_by, company_code, started_at, metadata
|
|
|
|
|
) VALUES (
|
|
|
|
|
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
|
|
|
|
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
|
|
|
|
)
|
|
|
|
|
`;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 2. **getAuditLogs()** - 감사 로그 목록 조회 (SELECT with filters)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Line 162
|
|
|
|
|
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
|
|
|
|
```
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- 동적 WHERE 조건 생성
|
|
|
|
|
- 페이징 (OFFSET, LIMIT)
|
|
|
|
|
- 정렬 (ORDER BY)
|
|
|
|
|
|
|
|
|
|
#### 3. **getAuditStats()** - 통계 조회 (복합 쿼리)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Line 199 - 총 통계
|
|
|
|
|
const totalStats = (await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT
|
|
|
|
|
COUNT(*) as total_executions,
|
|
|
|
|
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
|
|
|
|
|
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
|
|
|
|
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration
|
|
|
|
|
FROM ddl_audit_logs
|
|
|
|
|
WHERE ${whereClause}`
|
|
|
|
|
)) as any[];
|
|
|
|
|
|
|
|
|
|
// Line 212 - DDL 타입별 통계
|
|
|
|
|
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT ddl_type, COUNT(*) as count
|
|
|
|
|
FROM ddl_audit_logs
|
|
|
|
|
WHERE ${whereClause}
|
|
|
|
|
GROUP BY ddl_type
|
|
|
|
|
ORDER BY count DESC`
|
|
|
|
|
)) as any[];
|
|
|
|
|
|
|
|
|
|
// Line 224 - 사용자별 통계
|
|
|
|
|
const userStats = (await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT executed_by, COUNT(*) as count
|
|
|
|
|
FROM ddl_audit_logs
|
|
|
|
|
WHERE ${whereClause}
|
|
|
|
|
GROUP BY executed_by
|
|
|
|
|
ORDER BY count DESC
|
|
|
|
|
LIMIT 10`
|
|
|
|
|
)) as any[];
|
|
|
|
|
|
|
|
|
|
// Line 237 - 최근 실패 로그
|
|
|
|
|
const recentFailures = (await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT * FROM ddl_audit_logs
|
|
|
|
|
WHERE status = 'failed' AND ${whereClause}
|
|
|
|
|
ORDER BY started_at DESC
|
|
|
|
|
LIMIT 5`
|
|
|
|
|
)) as any[];
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 4. **getExecutionHistory()** - 실행 이력 조회
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Line 287
|
|
|
|
|
const history = await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT * FROM ddl_audit_logs
|
|
|
|
|
WHERE table_name = $1 AND company_code = $2
|
|
|
|
|
ORDER BY started_at DESC
|
|
|
|
|
LIMIT $3`,
|
|
|
|
|
tableName,
|
|
|
|
|
companyCode,
|
|
|
|
|
limit
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
#### 5. **cleanupOldLogs()** - 오래된 로그 삭제
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
// Line 320
|
|
|
|
|
const result = await prisma.$executeRaw`
|
|
|
|
|
DELETE FROM ddl_audit_logs
|
|
|
|
|
WHERE started_at < NOW() - INTERVAL '${retentionDays} days'
|
|
|
|
|
AND company_code = ${companyCode}
|
|
|
|
|
`;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 💡 전환 전략
|
|
|
|
|
|
|
|
|
|
### 1단계: $executeRaw 전환 (2개)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `logDDLStart()` - INSERT
|
|
|
|
|
- `cleanupOldLogs()` - DELETE
|
|
|
|
|
|
|
|
|
|
### 2단계: 단순 $queryRawUnsafe 전환 (1개)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `getExecutionHistory()` - 파라미터 바인딩 있음
|
|
|
|
|
|
|
|
|
|
### 3단계: 복잡한 $queryRawUnsafe 전환 (1개)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `getAuditLogs()` - 동적 WHERE 조건
|
|
|
|
|
|
|
|
|
|
### 4단계: 통계 쿼리 전환 (4개)
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `getAuditStats()` 내부의 4개 쿼리
|
|
|
|
|
- GROUP BY, CASE WHEN, AVG, EXTRACT
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 💻 전환 예시
|
|
|
|
|
|
|
|
|
|
### 예시 1: $executeRaw → query (INSERT)
|
|
|
|
|
|
|
|
|
|
**변경 전**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
const logEntry = await prisma.$executeRaw`
|
|
|
|
|
INSERT INTO ddl_audit_logs (
|
|
|
|
|
execution_id, ddl_type, table_name, status,
|
|
|
|
|
executed_by, company_code, started_at, metadata
|
|
|
|
|
) VALUES (
|
|
|
|
|
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
|
|
|
|
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
|
|
|
|
)
|
|
|
|
|
`;
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**변경 후**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
await query(
|
|
|
|
|
`INSERT INTO ddl_audit_logs (
|
|
|
|
|
execution_id, ddl_type, table_name, status,
|
|
|
|
|
executed_by, company_code, started_at, metadata
|
|
|
|
|
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::jsonb)`,
|
|
|
|
|
[
|
|
|
|
|
executionId,
|
|
|
|
|
ddlType,
|
|
|
|
|
tableName,
|
2025-10-01 12:01:04 +09:00
|
|
|
"in_progress",
|
2025-10-01 11:48:55 +09:00
|
|
|
executedBy,
|
|
|
|
|
companyCode,
|
|
|
|
|
JSON.stringify(metadata),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 예시 2: 동적 WHERE 조건
|
|
|
|
|
|
|
|
|
|
**변경 전**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
let query = `SELECT * FROM ddl_audit_logs WHERE 1=1`;
|
|
|
|
|
const params: any[] = [];
|
|
|
|
|
|
|
|
|
|
if (filters.ddlType) {
|
|
|
|
|
query += ` AND ddl_type = ?`;
|
|
|
|
|
params.push(filters.ddlType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**변경 후**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
const conditions: string[] = [];
|
|
|
|
|
const params: any[] = [];
|
|
|
|
|
let paramIndex = 1;
|
|
|
|
|
|
|
|
|
|
if (filters.ddlType) {
|
|
|
|
|
conditions.push(`ddl_type = $${paramIndex++}`);
|
|
|
|
|
params.push(filters.ddlType);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 12:01:04 +09:00
|
|
|
const whereClause =
|
|
|
|
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
2025-10-01 11:48:55 +09:00
|
|
|
const sql = `SELECT * FROM ddl_audit_logs ${whereClause}`;
|
|
|
|
|
|
|
|
|
|
const logs = await query<any>(sql, params);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 예시 3: 통계 쿼리 (GROUP BY)
|
|
|
|
|
|
|
|
|
|
**변경 전**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
|
|
|
|
`SELECT ddl_type, COUNT(*) as count
|
|
|
|
|
FROM ddl_audit_logs
|
|
|
|
|
WHERE ${whereClause}
|
|
|
|
|
GROUP BY ddl_type
|
|
|
|
|
ORDER BY count DESC`
|
|
|
|
|
)) as any[];
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**변경 후**:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
|
|
|
|
const ddlTypeStats = await query<{ ddl_type: string; count: string }>(
|
|
|
|
|
`SELECT ddl_type, COUNT(*) as count
|
|
|
|
|
FROM ddl_audit_logs
|
|
|
|
|
WHERE ${whereClause}
|
|
|
|
|
GROUP BY ddl_type
|
|
|
|
|
ORDER BY count DESC`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🔧 기술적 고려사항
|
|
|
|
|
|
|
|
|
|
### 1. JSON 필드 처리
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
`metadata` 필드는 JSONB 타입으로, INSERT 시 `::jsonb` 캐스팅 필요:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```typescript
|
2025-10-01 12:01:04 +09:00
|
|
|
JSON.stringify(metadata) + "::jsonb";
|
2025-10-01 11:48:55 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 2. 날짜/시간 함수
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `NOW()` - 현재 시간
|
|
|
|
|
- `INTERVAL '30 days'` - 날짜 간격
|
|
|
|
|
- `EXTRACT(EPOCH FROM ...)` - 초 단위 변환
|
|
|
|
|
|
|
|
|
|
### 3. CASE WHEN 집계
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
```sql
|
|
|
|
|
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 4. 동적 WHERE 조건
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
여러 필터를 조합하여 WHERE 절 생성:
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- ddlType
|
|
|
|
|
- tableName
|
|
|
|
|
- status
|
|
|
|
|
- executedBy
|
|
|
|
|
- dateRange (startDate, endDate)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
2025-10-01 12:01:04 +09:00
|
|
|
## ✅ 전환 완료 내역
|
|
|
|
|
|
|
|
|
|
### 전환된 Prisma 호출 (8개)
|
|
|
|
|
|
|
|
|
|
1. **`logDDLExecution()`** - DDL 실행 로그 INSERT
|
|
|
|
|
- Before: `prisma.$executeRaw`
|
|
|
|
|
- After: `query()` with 7 parameters
|
|
|
|
|
2. **`getAuditLogs()`** - 감사 로그 목록 조회
|
|
|
|
|
- Before: `prisma.$queryRawUnsafe`
|
|
|
|
|
- After: `query<any>()` with dynamic WHERE clause
|
|
|
|
|
3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리)
|
|
|
|
|
- Before: 4x `prisma.$queryRawUnsafe`
|
|
|
|
|
- After: 4x `query<any>()`
|
|
|
|
|
- totalStats: 전체 실행 통계 (CASE WHEN 집계)
|
|
|
|
|
- ddlTypeStats: DDL 타입별 통계 (GROUP BY)
|
|
|
|
|
- userStats: 사용자별 통계 (GROUP BY, LIMIT 10)
|
|
|
|
|
- recentFailures: 최근 실패 로그 (WHERE success = false)
|
|
|
|
|
4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리
|
|
|
|
|
- Before: `prisma.$queryRawUnsafe`
|
|
|
|
|
- After: `query<any>()` with table_name filter
|
|
|
|
|
5. **`cleanupOldLogs()`** - 오래된 로그 삭제
|
|
|
|
|
- Before: `prisma.$executeRaw`
|
|
|
|
|
- After: `query()` with date filter
|
|
|
|
|
|
|
|
|
|
### 주요 기술적 개선사항
|
|
|
|
|
|
|
|
|
|
1. **파라미터 바인딩**: PostgreSQL `$1, $2, ...` 스타일로 통일
|
|
|
|
|
2. **동적 WHERE 조건**: 파라미터 인덱스 자동 증가 로직 유지
|
|
|
|
|
3. **통계 쿼리**: CASE WHEN, GROUP BY, SUM 등 복잡한 집계 쿼리 완벽 전환
|
|
|
|
|
4. **에러 처리**: 기존 try-catch 구조 유지
|
|
|
|
|
5. **로깅**: logger 유틸리티 활용 유지
|
|
|
|
|
|
|
|
|
|
### 코드 정리
|
|
|
|
|
|
|
|
|
|
- [x] `import { PrismaClient }` 제거
|
|
|
|
|
- [x] `const prisma = new PrismaClient()` 제거
|
|
|
|
|
- [x] `import { query, queryOne }` 추가
|
|
|
|
|
- [x] 모든 타입 정의 유지
|
|
|
|
|
- [x] TypeScript 컴파일 성공
|
|
|
|
|
- [x] Linter 오류 없음
|
|
|
|
|
|
|
|
|
|
## 📝 원본 전환 체크리스트
|
|
|
|
|
|
|
|
|
|
### 1단계: Prisma 호출 전환 (✅ 완료)
|
2025-10-01 11:48:55 +09:00
|
|
|
|
|
|
|
|
- [ ] `logDDLStart()` - INSERT ($executeRaw → query)
|
|
|
|
|
- [ ] `logDDLComplete()` - UPDATE (이미 query 사용 중일 가능성)
|
|
|
|
|
- [ ] `logDDLError()` - UPDATE (이미 query 사용 중일 가능성)
|
|
|
|
|
- [ ] `getAuditLogs()` - SELECT with filters ($queryRawUnsafe → query)
|
|
|
|
|
- [ ] `getAuditStats()` 내 4개 쿼리:
|
|
|
|
|
- [ ] totalStats (집계 쿼리)
|
|
|
|
|
- [ ] ddlTypeStats (GROUP BY)
|
|
|
|
|
- [ ] userStats (GROUP BY + LIMIT)
|
|
|
|
|
- [ ] recentFailures (필터 + ORDER BY + LIMIT)
|
|
|
|
|
- [ ] `getExecutionHistory()` - SELECT with params ($queryRawUnsafe → query)
|
|
|
|
|
- [ ] `cleanupOldLogs()` - DELETE ($executeRaw → query)
|
|
|
|
|
|
|
|
|
|
### 2단계: 코드 정리
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
|
|
|
|
- [ ] Prisma import 완전 제거
|
|
|
|
|
- [ ] 타입 정의 확인
|
|
|
|
|
|
|
|
|
|
### 3단계: 테스트
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- [ ] 단위 테스트 작성 (8개)
|
|
|
|
|
- [ ] DDL 시작 로그 테스트
|
|
|
|
|
- [ ] DDL 완료 로그 테스트
|
|
|
|
|
- [ ] 감사 로그 목록 조회 테스트
|
|
|
|
|
- [ ] 통계 조회 테스트
|
|
|
|
|
- [ ] 실행 이력 조회 테스트
|
|
|
|
|
- [ ] 오래된 로그 삭제 테스트
|
|
|
|
|
- [ ] 통합 테스트 작성 (3개)
|
|
|
|
|
- [ ] 전체 DDL 실행 플로우 테스트
|
|
|
|
|
- [ ] 필터링 및 페이징 테스트
|
|
|
|
|
- [ ] 통계 정확성 테스트
|
|
|
|
|
- [ ] 성능 테스트
|
|
|
|
|
- [ ] 대량 로그 조회 성능
|
|
|
|
|
- [ ] 통계 쿼리 성능
|
|
|
|
|
|
|
|
|
|
### 4단계: 문서화
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- [ ] 전환 완료 문서 업데이트
|
|
|
|
|
- [ ] 주요 변경사항 기록
|
|
|
|
|
- [ ] 성능 벤치마크 결과
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🎯 예상 난이도 및 소요 시간
|
|
|
|
|
|
|
|
|
|
- **난이도**: ⭐⭐⭐ (중간)
|
|
|
|
|
- 복잡한 통계 쿼리 (GROUP BY, CASE WHEN)
|
|
|
|
|
- 동적 WHERE 조건 생성
|
|
|
|
|
- JSON 필드 처리
|
|
|
|
|
- **예상 소요 시간**: 1~1.5시간
|
|
|
|
|
- Prisma 호출 전환: 30분
|
|
|
|
|
- 테스트: 20분
|
|
|
|
|
- 문서화: 10분
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 📌 참고사항
|
|
|
|
|
|
|
|
|
|
### 관련 서비스
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `DDLExecutionService` - DDL 실행 (이미 전환 완료)
|
|
|
|
|
- `DDLSafetyValidator` - DDL 안전성 검증
|
|
|
|
|
|
|
|
|
|
### 의존성
|
2025-10-01 12:01:04 +09:00
|
|
|
|
2025-10-01 11:48:55 +09:00
|
|
|
- `../database/db` - query, queryOne 함수
|
|
|
|
|
- `../types/ddl` - DDL 관련 타입
|
|
|
|
|
- `../utils/logger` - 로깅
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
**상태**: ⏳ **대기 중**
|
|
|
|
|
**특이사항**: 통계 쿼리, JSON 필드, 동적 WHERE 조건 포함
|