타임스탬프 KST 통일 및 Meilisearch 동기화 소요 시간 추가

- date.js: nowKST() 함수 추가
- 모든 타임스탬프를 UTC에서 KST(+09:00)로 변경
  - scheduler.js, bots.js, x/index.js, logger.js, app.js
- Meilisearch 봇에 동기화 소요 시간(ms) 추적 추가
- BotCard.jsx: 중복된 마지막 동기화 대신 소요 시간 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-23 22:00:58 +09:00
parent 95285634e9
commit 85f03cb2d8
7 changed files with 45 additions and 27 deletions

View file

@ -9,6 +9,7 @@ import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import config from './config/index.js'; import config from './config/index.js';
import * as schemas from './schemas/index.js'; import * as schemas from './schemas/index.js';
import { nowKST } from './utils/date.js';
// 플러그인 // 플러그인
import dbPlugin from './plugins/db.js'; import dbPlugin from './plugins/db.js';
@ -116,7 +117,7 @@ export async function buildApp(opts = {}) {
// 헬스 체크 엔드포인트 // 헬스 체크 엔드포인트
fastify.get('/api/health', async () => { fastify.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() }; return { status: 'ok', timestamp: nowKST() };
}); });
// 봇 상태 조회 엔드포인트 // 봇 상태 조회 엔드포인트

View file

@ -2,6 +2,7 @@ import fp from 'fastify-plugin';
import cron from 'node-cron'; import cron from 'node-cron';
import bots from '../config/bots.js'; import bots from '../config/bots.js';
import { syncWithRetry, getVersion } from '../services/meilisearch/index.js'; import { syncWithRetry, getVersion } from '../services/meilisearch/index.js';
import { nowKST } from '../utils/date.js';
const REDIS_PREFIX = 'bot:status:'; const REDIS_PREFIX = 'bot:status:';
const TIMEZONE = 'Asia/Seoul'; const TIMEZONE = 'Asia/Seoul';
@ -14,7 +15,7 @@ async function schedulerPlugin(fastify, opts) {
*/ */
async function updateStatus(botId, status) { async function updateStatus(botId, status) {
const current = await getStatus(botId); const current = await getStatus(botId);
const updated = { ...current, ...status, updatedAt: new Date().toISOString() }; const updated = { ...current, ...status, updatedAt: nowKST() };
await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated)); await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated));
return updated; return updated;
} }
@ -32,6 +33,7 @@ async function schedulerPlugin(fastify, opts) {
lastCheckAt: null, lastCheckAt: null,
lastAddedCount: 0, lastAddedCount: 0,
totalAdded: 0, totalAdded: 0,
lastSyncDuration: null,
errorMessage: null, errorMessage: null,
}; };
} }
@ -99,7 +101,7 @@ async function schedulerPlugin(fastify, opts) {
fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`); fastify.log.info(`[${botId}] 버전 변경 없음, 체크 종료`);
await updateStatus(botId, { await updateStatus(botId, {
status: 'running', status: 'running',
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
}); });
} }
return; return;
@ -136,20 +138,25 @@ async function schedulerPlugin(fastify, opts) {
* 동기화 실행 상태 업데이트 * 동기화 실행 상태 업데이트
*/ */
async function performSync(botId, newVersion, versionKey) { async function performSync(botId, newVersion, versionKey) {
const startTime = Date.now();
try { try {
const count = await syncWithRetry(fastify.meilisearch, fastify.db); const count = await syncWithRetry(fastify.meilisearch, fastify.db);
const duration = Date.now() - startTime;
await fastify.redis.set(versionKey, newVersion); await fastify.redis.set(versionKey, newVersion);
await updateStatus(botId, { await updateStatus(botId, {
status: 'running', status: 'running',
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
lastAddedCount: count, lastAddedCount: count,
lastSyncDuration: duration,
errorMessage: null, errorMessage: null,
}); });
fastify.log.info(`[${botId}] 동기화 완료: ${count}개, 새 버전: ${newVersion}`); fastify.log.info(`[${botId}] 동기화 완료: ${count}개, ${duration}ms, 새 버전: ${newVersion}`);
} catch (err) { } catch (err) {
const duration = Date.now() - startTime;
await updateStatus(botId, { await updateStatus(botId, {
status: 'error', status: 'error',
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
lastSyncDuration: duration,
errorMessage: err.message, errorMessage: err.message,
}); });
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);
@ -163,7 +170,7 @@ async function schedulerPlugin(fastify, opts) {
const { setRunningStatus = false, setErrorOnFail = false } = options; const { setRunningStatus = false, setErrorOnFail = false } = options;
const status = await getStatus(botId); const status = await getStatus(botId);
const updateData = { const updateData = {
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
totalAdded: (status.totalAdded || 0) + result.addedCount, totalAdded: (status.totalAdded || 0) + result.addedCount,
}; };
if (setRunningStatus) { if (setRunningStatus) {
@ -213,7 +220,7 @@ async function schedulerPlugin(fastify, opts) {
} catch (err) { } catch (err) {
await updateStatus(botId, { await updateStatus(botId, {
status: 'error', status: 'error',
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
errorMessage: err.message, errorMessage: err.message,
}); });
fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`);

View file

@ -2,6 +2,7 @@ import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js'; import { errorResponse } from '../../schemas/index.js';
import { syncAllSchedules } from '../../services/meilisearch/index.js'; import { syncAllSchedules } from '../../services/meilisearch/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js'; import { badRequest, notFound, serverError } from '../../utils/error.js';
import { nowKST } from '../../utils/date.js';
// 봇 관련 스키마 // 봇 관련 스키마
const botResponse = { const botResponse = {
@ -13,6 +14,7 @@ const botResponse = {
status: { type: 'string', enum: ['running', 'stopped', 'error'] }, status: { type: 'string', enum: ['running', 'stopped', 'error'] },
last_check_at: { type: 'string', format: 'date-time' }, last_check_at: { type: 'string', format: 'date-time' },
last_added_count: { type: 'integer' }, last_added_count: { type: 'integer' },
last_sync_duration: { type: 'integer', description: '마지막 동기화 소요 시간 (ms)' },
schedules_added: { type: 'integer' }, schedules_added: { type: 'integer' },
check_interval: { type: 'integer' }, check_interval: { type: 'integer' },
error_message: { type: 'string' }, error_message: { type: 'string' },
@ -74,6 +76,7 @@ export default async function botsRoutes(fastify) {
status: status.status, status: status.status,
last_check_at: status.lastCheckAt, last_check_at: status.lastCheckAt,
last_added_count: status.lastAddedCount, last_added_count: status.lastAddedCount,
last_sync_duration: status.lastSyncDuration,
schedules_added: status.totalAdded, schedules_added: status.totalAdded,
check_interval: checkInterval, check_interval: checkInterval,
error_message: status.errorMessage, error_message: status.errorMessage,
@ -194,6 +197,7 @@ export default async function botsRoutes(fastify) {
return notFound(reply, '봇을 찾을 수 없습니다.'); return notFound(reply, '봇을 찾을 수 없습니다.');
} }
const startTime = Date.now();
try { try {
let result; let result;
if (bot.type === 'youtube') { if (bot.type === 'youtube') {
@ -207,14 +211,17 @@ export default async function botsRoutes(fastify) {
return badRequest(reply, '지원하지 않는 봇 타입입니다.'); return badRequest(reply, '지원하지 않는 봇 타입입니다.');
} }
const duration = Date.now() - startTime;
// 상태 업데이트 // 상태 업데이트
const status = await scheduler.getStatus(id); const status = await scheduler.getStatus(id);
await fastify.redis.set(`bot:status:${id}`, JSON.stringify({ await fastify.redis.set(`bot:status:${id}`, JSON.stringify({
...status, ...status,
lastCheckAt: new Date().toISOString(), lastCheckAt: nowKST(),
lastAddedCount: result.addedCount, lastAddedCount: result.addedCount,
lastSyncDuration: duration,
totalAdded: (status.totalAdded || 0) + result.addedCount, totalAdded: (status.totalAdded || 0) + result.addedCount,
updatedAt: new Date().toISOString(), updatedAt: nowKST(),
})); }));
return { return {

View file

@ -1,7 +1,7 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js';
import { fetchVideoInfo } from '../youtube/api.js'; import { fetchVideoInfo } from '../youtube/api.js';
import { formatDate, formatTime } from '../../utils/date.js'; import { formatDate, formatTime, nowKST } from '../../utils/date.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { withTransaction } from '../../utils/transaction.js'; import { withTransaction } from '../../utils/transaction.js';
@ -41,7 +41,7 @@ async function xBotPlugin(fastify, opts) {
username, username,
displayName: profile.displayName, displayName: profile.displayName,
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
updatedAt: new Date().toISOString(), updatedAt: nowKST(),
}; };
await fastify.redis.setex( await fastify.redis.setex(
`${PROFILE_CACHE_PREFIX}${username}`, `${PROFILE_CACHE_PREFIX}${username}`,

View file

@ -28,6 +28,14 @@ export function formatTime(date) {
return dayjs(date).tz(KST).format('HH:mm:ss'); return dayjs(date).tz(KST).format('HH:mm:ss');
} }
/**
* 현재 KST 시간을 ISO 형식으로 반환
* : "2025-01-23T13:05:00+09:00"
*/
export function nowKST() {
return dayjs().tz(KST).format();
}
/** /**
* Nitter 날짜 문자열 파싱 * Nitter 날짜 문자열 파싱
* : "Jan 15, 2026 · 10:30 PM UTC" * : "Jan 15, 2026 · 10:30 PM UTC"

View file

@ -2,6 +2,7 @@
* 로거 유틸리티 * 로거 유틸리티
* 서비스 레이어에서 사용할 있는 간단한 로깅 유틸리티 * 서비스 레이어에서 사용할 있는 간단한 로깅 유틸리티
*/ */
import { nowKST } from './date.js';
const PREFIX = { const PREFIX = {
info: '[INFO]', info: '[INFO]',
@ -11,7 +12,7 @@ const PREFIX = {
}; };
function formatMessage(level, context, message) { function formatMessage(level, context, message) {
const timestamp = new Date().toISOString(); const timestamp = nowKST();
return `${timestamp} ${PREFIX[level]} [${context}] ${message}`; return `${timestamp} ${PREFIX[level]} [${context}] ${message}`;
} }

View file

@ -144,26 +144,20 @@ const BotCard = memo(function BotCard({
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50"> <div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
{bot.type === 'meilisearch' ? ( {bot.type === 'meilisearch' ? (
<> <>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">
{bot.last_check_at
? new Date(bot.last_check_at).toLocaleString('ko-KR', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
: '-'}
</div>
<div className="text-xs text-gray-400">마지막 동기화</div>
</div>
<div className="p-3 text-center"> <div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900"> <div className="text-lg font-bold text-gray-900">
{bot.last_added_count?.toLocaleString() || '-'} {bot.last_added_count?.toLocaleString() || '-'}
</div> </div>
<div className="text-xs text-gray-400">동기화 </div> <div className="text-xs text-gray-400">동기화 </div>
</div> </div>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">
{bot.last_sync_duration != null
? `${(bot.last_sync_duration / 1000).toFixed(1)}`
: '-'}
</div>
<div className="text-xs text-gray-400">소요 시간</div>
</div>
<div className="p-3 text-center"> <div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{bot.version || '-'}</div> <div className="text-lg font-bold text-gray-900">{bot.version || '-'}</div>
<div className="text-xs text-gray-400">버전</div> <div className="text-xs text-gray-400">버전</div>