perf: 동기화 현황 데이터 로딩 속도 개선 (#9)
- SyncStatusService: 테이블당 2쿼리 → 1쿼리로 합침 (94→47 쿼리) - ORDER BY 기준 mdfcn_dt → crt_dt 변경 - Caffeine 캐시 적용 (TTL 10분): syncStatus, syncDataPreview, syncStuckRecords - 새로고침 버튼 시 전체 캐시 무효화 + DB 재조회 - 30초 폴링 제거 → 페이지 진입 1회 조회 + 수동 새로고침 - 네비게이션바 순서 변경: 대시보드 → 동기화 현황 - docs/create-sync-indexes.sql: std_snp_data + std_snp_svc 인덱스 DDL Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
c15de4641a
커밋
2f64eae925
163
docs/create-sync-indexes.sql
Normal file
163
docs/create-sync-indexes.sql
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 동기화 현황 조회 성능 개선 인덱스
|
||||||
|
-- 대상 스키마: std_snp_data
|
||||||
|
--
|
||||||
|
-- 쿼리 패턴:
|
||||||
|
-- SELECT batch_flag, COUNT(*), MAX(CASE WHEN batch_flag='S' THEN crt_dt END)
|
||||||
|
-- FROM table a
|
||||||
|
-- INNER JOIN batch_job_execution b ON a.job_execution_id = b.job_execution_id AND b.status = 'COMPLETED'
|
||||||
|
-- WHERE a.batch_flag IN ('N', 'P', 'S')
|
||||||
|
-- GROUP BY a.batch_flag
|
||||||
|
--
|
||||||
|
-- 인덱스 전략:
|
||||||
|
-- 1. 각 소스 테이블: (batch_flag, job_execution_id) 복합 인덱스
|
||||||
|
-- → batch_flag 필터링 + JOIN 조건을 인덱스만으로 처리
|
||||||
|
-- → crt_dt INCLUDE로 커버링 인덱스 (Index Only Scan 가능)
|
||||||
|
-- 2. batch_job_execution: (job_execution_id, status) 복합 인덱스
|
||||||
|
-- → JOIN 조건을 인덱스만으로 처리
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- batch_job_execution 테이블 (1회만 실행)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_batch_job_execution_status
|
||||||
|
ON std_snp_data.batch_job_execution (job_execution_id, status);
|
||||||
|
|
||||||
|
-- Ship 테이블
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_info_mst_sync ON std_snp_data.tb_ship_info_mst (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_add_info_sync ON std_snp_data.tb_ship_add_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_bbctr_hstry_sync ON std_snp_data.tb_ship_bbctr_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_idntf_info_hstry_sync ON std_snp_data.tb_ship_idntf_info_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_clfic_hstry_sync ON std_snp_data.tb_ship_clfic_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_company_rel_sync ON std_snp_data.tb_ship_company_rel (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_crew_list_sync ON std_snp_data.tb_ship_crew_list (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_dark_actv_idnty_sync ON std_snp_data.tb_ship_dark_actv_idnty (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_country_hstry_sync ON std_snp_data.tb_ship_country_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_group_revn_ownr_hstry_sync ON std_snp_data.tb_ship_group_revn_ownr_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_ice_grd_sync ON std_snp_data.tb_ship_ice_grd (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_nm_chg_hstry_sync ON std_snp_data.tb_ship_nm_chg_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_operator_hstry_sync ON std_snp_data.tb_ship_operator_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_ownr_hstry_sync ON std_snp_data.tb_ship_ownr_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_prtc_rpn_hstry_sync ON std_snp_data.tb_ship_prtc_rpn_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_sfty_mng_evdc_hstry_sync ON std_snp_data.tb_ship_sfty_mng_evdc_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_mng_company_hstry_sync ON std_snp_data.tb_ship_mng_company_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_sstrvsl_rel_sync ON std_snp_data.tb_ship_sstrvsl_rel (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_spc_fetr_sync ON std_snp_data.tb_ship_spc_fetr (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_status_hstry_sync ON std_snp_data.tb_ship_status_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_cargo_capacity_sync ON std_snp_data.tb_ship_cargo_capacity (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_inspection_ymd_sync ON std_snp_data.tb_ship_inspection_ymd (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_inspection_ymd_hstry_sync ON std_snp_data.tb_ship_inspection_ymd_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_tech_mng_company_hstry_sync ON std_snp_data.tb_ship_tech_mng_company_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_thrstr_info_sync ON std_snp_data.tb_ship_thrstr_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Company
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_dtl_info_sync ON std_snp_data.tb_company_dtl_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Event
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_mst_sync ON std_snp_data.tb_event_mst (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_cargo_sync ON std_snp_data.tb_event_cargo (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_humn_acdnt_sync ON std_snp_data.tb_event_humn_acdnt (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_rel_sync ON std_snp_data.tb_event_rel (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Facility
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_port_facility_info_sync ON std_snp_data.tb_port_facility_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- PSC
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_psc_mst_sync ON std_snp_data.tb_psc_mst (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_psc_defect_sync ON std_snp_data.tb_psc_defect (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_psc_oa_certf_sync ON std_snp_data.tb_psc_oa_certf (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Movements
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_anchrgcall_hstry_sync ON std_snp_data.tb_ship_anchrgcall_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_berthcall_hstry_sync ON std_snp_data.tb_ship_berthcall_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_now_status_hstry_sync ON std_snp_data.tb_ship_now_status_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_dest_hstry_sync ON std_snp_data.tb_ship_dest_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_prtcll_hstry_sync ON std_snp_data.tb_ship_prtcll_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_sts_opert_hstry_sync ON std_snp_data.tb_ship_sts_opert_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_teminalcall_hstry_sync ON std_snp_data.tb_ship_teminalcall_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_trnst_hstry_sync ON std_snp_data.tb_ship_trnst_hstry (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Code
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_type_cd_sync ON std_snp_data.tb_ship_type_cd (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_country_cd_sync ON std_snp_data.tb_ship_country_cd (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- Risk & Compliance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_risk_info_sync ON std_snp_data.tb_ship_risk_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ship_compliance_info_sync ON std_snp_data.tb_ship_compliance_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_compliance_info_sync ON std_snp_data.tb_company_compliance_info (batch_flag, job_execution_id) INCLUDE (crt_dt);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- std_snp_svc (타겟 스키마) - 데이터 미리보기 조회 성능 개선
|
||||||
|
--
|
||||||
|
-- 쿼리 패턴:
|
||||||
|
-- SELECT * FROM std_snp_svc.table ORDER BY crt_dt DESC NULLS LAST LIMIT 20
|
||||||
|
--
|
||||||
|
-- 인덱스 전략:
|
||||||
|
-- crt_dt DESC 인덱스 → ORDER BY + LIMIT을 Index Scan으로 처리
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Ship
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_info_mst_crt ON std_snp_svc.tb_ship_info_mst (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_add_info_crt ON std_snp_svc.tb_ship_add_info (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_bbctr_hstry_crt ON std_snp_svc.tb_ship_bbctr_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_idntf_info_hstry_crt ON std_snp_svc.tb_ship_idntf_info_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_clfic_hstry_crt ON std_snp_svc.tb_ship_clfic_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_company_rel_crt ON std_snp_svc.tb_ship_company_rel (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_crew_list_crt ON std_snp_svc.tb_ship_crew_list (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_dark_actv_idnty_crt ON std_snp_svc.tb_ship_dark_actv_idnty (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_country_hstry_crt ON std_snp_svc.tb_ship_country_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_group_revn_ownr_hstry_crt ON std_snp_svc.tb_ship_group_revn_ownr_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_ice_grd_crt ON std_snp_svc.tb_ship_ice_grd (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_nm_chg_hstry_crt ON std_snp_svc.tb_ship_nm_chg_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_operator_hstry_crt ON std_snp_svc.tb_ship_operator_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_ownr_hstry_crt ON std_snp_svc.tb_ship_ownr_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_prtc_rpn_hstry_crt ON std_snp_svc.tb_ship_prtc_rpn_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_sfty_mng_evdc_hstry_crt ON std_snp_svc.tb_ship_sfty_mng_evdc_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_mng_company_hstry_crt ON std_snp_svc.tb_ship_mng_company_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_sstrvsl_rel_crt ON std_snp_svc.tb_ship_sstrvsl_rel (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_spc_fetr_crt ON std_snp_svc.tb_ship_spc_fetr (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_status_hstry_crt ON std_snp_svc.tb_ship_status_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_cargo_capacity_crt ON std_snp_svc.tb_ship_cargo_capacity (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_inspection_ymd_crt ON std_snp_svc.tb_ship_inspection_ymd (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_inspection_ymd_hstry_crt ON std_snp_svc.tb_ship_inspection_ymd_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_tech_mng_company_hstry_crt ON std_snp_svc.tb_ship_tech_mng_company_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_thrstr_info_crt ON std_snp_svc.tb_ship_thrstr_info (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Company
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_company_dtl_info_crt ON std_snp_svc.tb_company_dtl_info (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Event
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_event_mst_crt ON std_snp_svc.tb_event_mst (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_event_cargo_crt ON std_snp_svc.tb_event_cargo (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_event_humn_acdnt_crt ON std_snp_svc.tb_event_humn_acdnt (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_event_rel_crt ON std_snp_svc.tb_event_rel (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Facility
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_port_facility_info_crt ON std_snp_svc.tb_port_facility_info (crt_dt DESC);
|
||||||
|
|
||||||
|
-- PSC
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_psc_mst_crt ON std_snp_svc.tb_psc_mst (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_psc_defect_crt ON std_snp_svc.tb_psc_defect (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_psc_oa_certf_crt ON std_snp_svc.tb_psc_oa_certf (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Movements
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_anchrgcall_hstry_crt ON std_snp_svc.tb_ship_anchrgcall_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_berthcall_hstry_crt ON std_snp_svc.tb_ship_berthcall_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_now_status_hstry_crt ON std_snp_svc.tb_ship_now_status_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_dest_hstry_crt ON std_snp_svc.tb_ship_dest_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_prtcll_hstry_crt ON std_snp_svc.tb_ship_prtcll_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_sts_opert_hstry_crt ON std_snp_svc.tb_ship_sts_opert_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_teminalcall_hstry_crt ON std_snp_svc.tb_ship_teminalcall_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_trnst_hstry_crt ON std_snp_svc.tb_ship_trnst_hstry (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Code
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_type_cd_crt ON std_snp_svc.tb_ship_type_cd (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_country_cd_crt ON std_snp_svc.tb_ship_country_cd (crt_dt DESC);
|
||||||
|
|
||||||
|
-- Risk & Compliance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_risk_info_crt ON std_snp_svc.tb_ship_risk_info (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_risk_hstry_crt ON std_snp_svc.tb_ship_risk_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_compliance_info_crt ON std_snp_svc.tb_ship_compliance_info (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_compliance_hstry_crt ON std_snp_svc.tb_ship_compliance_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_ship_compliance_info_hstry_crt ON std_snp_svc.tb_ship_compliance_info_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_company_compliance_info_crt ON std_snp_svc.tb_company_compliance_info (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_company_compliance_hstry_crt ON std_snp_svc.tb_company_compliance_hstry (crt_dt DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_svc_company_compliance_info_hstry_crt ON std_snp_svc.tb_company_compliance_info_hstry (crt_dt DESC);
|
||||||
@ -453,8 +453,8 @@ export const batchApi = {
|
|||||||
`${BASE}/failed-records/reset-retry`, { ids }),
|
`${BASE}/failed-records/reset-retry`, { ids }),
|
||||||
|
|
||||||
// Sync Status
|
// Sync Status
|
||||||
getSyncStatus: () =>
|
getSyncStatus: (refresh = false) =>
|
||||||
fetchJson<SyncStatusResponse>(`${BASE}/sync-status`),
|
fetchJson<SyncStatusResponse>(`${BASE}/sync-status?refresh=${refresh}`),
|
||||||
|
|
||||||
getSyncDataPreview: (tableKey: string, limit = 10) =>
|
getSyncDataPreview: (tableKey: string, limit = 10) =>
|
||||||
fetchJson<SyncDataPreviewResponse>(`${BASE}/sync-status/${tableKey}/preview?limit=${limit}`),
|
fetchJson<SyncDataPreviewResponse>(`${BASE}/sync-status/${tableKey}/preview?limit=${limit}`),
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import { useThemeContext } from '../contexts/ThemeContext';
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: '대시보드', icon: '📊' },
|
{ path: '/', label: '대시보드', icon: '📊' },
|
||||||
|
{ path: '/sync-status', label: '동기화 현황', icon: '🔄' },
|
||||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||||
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
{ path: '/jobs', label: '작업', icon: '⚙️' },
|
||||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||||
{ path: '/sync-status', label: '동기화 현황', icon: '🔄' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
|||||||
@ -5,15 +5,12 @@ import {
|
|||||||
type SyncTableStatus,
|
type SyncTableStatus,
|
||||||
type SyncDataPreviewResponse,
|
type SyncDataPreviewResponse,
|
||||||
} from '../api/batchApi';
|
} from '../api/batchApi';
|
||||||
import { usePoller } from '../hooks/usePoller';
|
|
||||||
import { useToastContext } from '../contexts/ToastContext';
|
import { useToastContext } from '../contexts/ToastContext';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import EmptyState from '../components/EmptyState';
|
import EmptyState from '../components/EmptyState';
|
||||||
import ConfirmModal from '../components/ConfirmModal';
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
import GuideModal, { HelpButton } from '../components/GuideModal';
|
import GuideModal, { HelpButton } from '../components/GuideModal';
|
||||||
|
|
||||||
const POLLING_INTERVAL = 30000;
|
|
||||||
|
|
||||||
const DOMAIN_ICONS: Record<string, string> = {
|
const DOMAIN_ICONS: Record<string, string> = {
|
||||||
ship: '🚢',
|
ship: '🚢',
|
||||||
company: '🏢',
|
company: '🏢',
|
||||||
@ -56,19 +53,27 @@ export default function SyncStatus() {
|
|||||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async (refresh = false) => {
|
||||||
try {
|
try {
|
||||||
const result = await batchApi.getSyncStatus();
|
const result = await batchApi.getSyncStatus(refresh);
|
||||||
setData(result);
|
setData(result);
|
||||||
} catch {
|
} catch {
|
||||||
if (loading) showToast('동기화 현황 조회 실패', 'error');
|
showToast('동기화 현황 조회 실패', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
usePoller(loadData, POLLING_INTERVAL);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
loadData(true);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAccordion = (tableKey: string) => {
|
const toggleAccordion = (tableKey: string) => {
|
||||||
setExpandedTable((prev) => (prev === tableKey ? '' : tableKey));
|
setExpandedTable((prev) => (prev === tableKey ? '' : tableKey));
|
||||||
@ -106,8 +111,22 @@ export default function SyncStatus() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-wing-text">동기화 현황</h1>
|
<h1 className="text-2xl font-bold text-wing-text">동기화 현황</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-wing-border transition-colors
|
||||||
|
${refreshing
|
||||||
|
? 'text-wing-muted cursor-not-allowed'
|
||||||
|
: 'text-wing-text hover:bg-wing-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={refreshing ? 'animate-spin' : ''}>↻</span>
|
||||||
|
{refreshing ? '조회 중...' : '새로고침'}
|
||||||
|
</button>
|
||||||
<HelpButton onClick={() => setGuideOpen(true)} />
|
<HelpButton onClick={() => setGuideOpen(true)} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Domain Tabs ── */}
|
{/* ── Domain Tabs ── */}
|
||||||
<div className="flex gap-0 overflow-x-auto border-b border-wing-border mb-4">
|
<div className="flex gap-0 overflow-x-auto border-b border-wing-border mb-4">
|
||||||
|
|||||||
10
pom.xml
10
pom.xml
@ -106,6 +106,16 @@
|
|||||||
<version>2.3.0</version>
|
<version>2.3.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Cache -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Test Dependencies -->
|
<!-- Test Dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
24
src/main/java/com/snp/batch/global/config/CacheConfig.java
Normal file
24
src/main/java/com/snp/batch/global/config/CacheConfig.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package com.snp.batch.global.config;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
public class CacheConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager() {
|
||||||
|
CaffeineCacheManager manager = new CaffeineCacheManager("syncStatus", "syncDataPreview", "syncStuckRecords");
|
||||||
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
.maximumSize(100));
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -388,9 +388,13 @@ public class BatchController {
|
|||||||
|
|
||||||
@Operation(summary = "동기화 현황 조회", description = "전체 테이블의 batch_flag 기반 동기화 현황을 조회합니다")
|
@Operation(summary = "동기화 현황 조회", description = "전체 테이블의 batch_flag 기반 동기화 현황을 조회합니다")
|
||||||
@GetMapping("/sync-status")
|
@GetMapping("/sync-status")
|
||||||
public ResponseEntity<SyncStatusResponse> getSyncStatus() {
|
public ResponseEntity<SyncStatusResponse> getSyncStatus(
|
||||||
log.info("Received request to get sync status");
|
@Parameter(description = "캐시 무시 여부") @RequestParam(defaultValue = "false") boolean refresh) {
|
||||||
|
log.info("Received request to get sync status (refresh: {})", refresh);
|
||||||
try {
|
try {
|
||||||
|
if (refresh) {
|
||||||
|
syncStatusService.evictAllCaches();
|
||||||
|
}
|
||||||
SyncStatusResponse status = syncStatusService.getSyncStatus();
|
SyncStatusResponse status = syncStatusService.getSyncStatus();
|
||||||
return ResponseEntity.ok(status);
|
return ResponseEntity.ok(status);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import com.snp.batch.global.dto.SyncStatusResponse.SyncStatusSummary;
|
|||||||
import com.snp.batch.global.dto.SyncStatusResponse.SyncTableStatus;
|
import com.snp.batch.global.dto.SyncStatusResponse.SyncTableStatus;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.cache.annotation.Caching;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -63,8 +66,9 @@ public class SyncStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체 동기화 현황 조회
|
* 전체 동기화 현황 조회 (10분 TTL 캐싱)
|
||||||
*/
|
*/
|
||||||
|
@Cacheable(value = "syncStatus", key = "'all'")
|
||||||
public SyncStatusResponse getSyncStatus() {
|
public SyncStatusResponse getSyncStatus() {
|
||||||
// 테이블을 병렬 조회 (HikariCP pool=10 기준 동시 10개)
|
// 테이블을 병렬 조회 (HikariCP pool=10 기준 동시 10개)
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(
|
ExecutorService executor = Executors.newFixedThreadPool(
|
||||||
@ -136,8 +140,9 @@ public class SyncStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 테이블의 최근 동기화 데이터 미리보기
|
* 특정 테이블의 최근 동기화 데이터 미리보기 (10분 TTL 캐싱)
|
||||||
*/
|
*/
|
||||||
|
@Cacheable(value = "syncDataPreview", key = "#tableKey + '-' + #limit")
|
||||||
public SyncDataPreviewResponse getDataPreview(String tableKey, int limit) {
|
public SyncDataPreviewResponse getDataPreview(String tableKey, int limit) {
|
||||||
String targetTable = targetTables.get(tableKey);
|
String targetTable = targetTables.get(tableKey);
|
||||||
if (targetTable == null) {
|
if (targetTable == null) {
|
||||||
@ -147,7 +152,7 @@ public class SyncStatusService {
|
|||||||
String countSql = "SELECT COUNT(*) FROM %s.%s".formatted(targetSchema, targetTable);
|
String countSql = "SELECT COUNT(*) FROM %s.%s".formatted(targetSchema, targetTable);
|
||||||
Long totalCount = businessJdbc.queryForObject(countSql, Long.class);
|
Long totalCount = businessJdbc.queryForObject(countSql, Long.class);
|
||||||
|
|
||||||
String sql = "SELECT * FROM %s.%s ORDER BY mdfcn_dt DESC NULLS LAST LIMIT %d"
|
String sql = "SELECT * FROM %s.%s ORDER BY crt_dt DESC NULLS LAST LIMIT %d"
|
||||||
.formatted(targetSchema, targetTable, limit);
|
.formatted(targetSchema, targetTable, limit);
|
||||||
|
|
||||||
List<Map<String, Object>> rows = businessJdbc.queryForList(sql);
|
List<Map<String, Object>> rows = businessJdbc.queryForList(sql);
|
||||||
@ -167,8 +172,9 @@ public class SyncStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P 상태 고착 레코드 조회
|
* P 상태 고착 레코드 조회 (10분 TTL 캐싱)
|
||||||
*/
|
*/
|
||||||
|
@Cacheable(value = "syncStuckRecords", key = "#tableKey + '-' + #limit")
|
||||||
public SyncDataPreviewResponse getStuckRecords(String tableKey, int limit) {
|
public SyncDataPreviewResponse getStuckRecords(String tableKey, int limit) {
|
||||||
String sourceTable = sourceTables.get(tableKey);
|
String sourceTable = sourceTables.get(tableKey);
|
||||||
if (sourceTable == null) {
|
if (sourceTable == null) {
|
||||||
@ -192,7 +198,7 @@ public class SyncStatusService {
|
|||||||
ON a.job_execution_id = b.job_execution_id
|
ON a.job_execution_id = b.job_execution_id
|
||||||
AND b.status = 'COMPLETED'
|
AND b.status = 'COMPLETED'
|
||||||
WHERE a.batch_flag = 'P'
|
WHERE a.batch_flag = 'P'
|
||||||
ORDER BY a.mdfcn_dt DESC NULLS LAST
|
ORDER BY a.crt_dt DESC NULLS LAST
|
||||||
LIMIT %d
|
LIMIT %d
|
||||||
""".formatted(sourceSchema, sourceTable, sourceSchema, limit);
|
""".formatted(sourceSchema, sourceTable, sourceSchema, limit);
|
||||||
|
|
||||||
@ -213,8 +219,24 @@ public class SyncStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* P 상태 고착 레코드를 N으로 리셋
|
* 전체 캐시 무효화
|
||||||
*/
|
*/
|
||||||
|
@Caching(evict = {
|
||||||
|
@CacheEvict(value = "syncStatus", allEntries = true),
|
||||||
|
@CacheEvict(value = "syncDataPreview", allEntries = true),
|
||||||
|
@CacheEvict(value = "syncStuckRecords", allEntries = true)
|
||||||
|
})
|
||||||
|
public void evictAllCaches() {
|
||||||
|
log.info("동기화 현황 캐시 전체 무효화");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P 상태 고착 레코드를 N으로 리셋 (관련 캐시 무효화)
|
||||||
|
*/
|
||||||
|
@Caching(evict = {
|
||||||
|
@CacheEvict(value = "syncStatus", allEntries = true),
|
||||||
|
@CacheEvict(value = "syncStuckRecords", allEntries = true)
|
||||||
|
})
|
||||||
public int resetStuckRecords(String tableKey) {
|
public int resetStuckRecords(String tableKey) {
|
||||||
String sourceTable = sourceTables.get(tableKey);
|
String sourceTable = sourceTables.get(tableKey);
|
||||||
if (sourceTable == null) {
|
if (sourceTable == null) {
|
||||||
@ -235,10 +257,11 @@ public class SyncStatusService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private SyncTableStatus queryTableStatus(String tableKey, String sourceTable, String targetTable) {
|
private SyncTableStatus queryTableStatus(String tableKey, String sourceTable, String targetTable) {
|
||||||
// batch_job_execution.status = 'COMPLETED'인 데이터만 집계
|
// batch_flag 집계 + 최근 동기화 시간을 1개 쿼리로 조회
|
||||||
// (수집/적재가 완전히 완료된 데이터만 동기화 대상)
|
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT a.batch_flag, COUNT(*) AS cnt
|
SELECT a.batch_flag,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
MAX(CASE WHEN a.batch_flag = 'S' THEN a.crt_dt END) AS last_sync
|
||||||
FROM %s.%s a
|
FROM %s.%s a
|
||||||
INNER JOIN %s.batch_job_execution b
|
INNER JOIN %s.batch_job_execution b
|
||||||
ON a.job_execution_id = b.job_execution_id
|
ON a.job_execution_id = b.job_execution_id
|
||||||
@ -251,27 +274,16 @@ public class SyncStatusService {
|
|||||||
counts.put("N", 0L);
|
counts.put("N", 0L);
|
||||||
counts.put("P", 0L);
|
counts.put("P", 0L);
|
||||||
counts.put("S", 0L);
|
counts.put("S", 0L);
|
||||||
|
final String[] lastSyncTime = {null};
|
||||||
|
|
||||||
businessJdbc.query(sql, rs -> {
|
businessJdbc.query(sql, rs -> {
|
||||||
counts.put(rs.getString("batch_flag"), rs.getLong("cnt"));
|
String flag = rs.getString("batch_flag");
|
||||||
});
|
counts.put(flag, rs.getLong("cnt"));
|
||||||
|
String lastSync = rs.getString("last_sync");
|
||||||
// 최근 동기화 시간 (COMPLETED된 job의 batch_flag='S'인 가장 최근 mdfcn_dt)
|
if (lastSync != null) {
|
||||||
String lastSyncSql = """
|
lastSyncTime[0] = lastSync;
|
||||||
SELECT MAX(a.mdfcn_dt)
|
|
||||||
FROM %s.%s a
|
|
||||||
INNER JOIN %s.batch_job_execution b
|
|
||||||
ON a.job_execution_id = b.job_execution_id
|
|
||||||
AND b.status = 'COMPLETED'
|
|
||||||
WHERE a.batch_flag = 'S'
|
|
||||||
""".formatted(sourceSchema, sourceTable, sourceSchema);
|
|
||||||
|
|
||||||
String lastSyncTime = null;
|
|
||||||
try {
|
|
||||||
lastSyncTime = businessJdbc.queryForObject(lastSyncSql, String.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.trace("최근 동기화 시간 조회 실패: {}", tableKey);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
boolean stuck = counts.get("P") > 0;
|
boolean stuck = counts.get("P") > 0;
|
||||||
|
|
||||||
@ -283,7 +295,7 @@ public class SyncStatusService {
|
|||||||
.pendingCount(counts.get("N"))
|
.pendingCount(counts.get("N"))
|
||||||
.processingCount(counts.get("P"))
|
.processingCount(counts.get("P"))
|
||||||
.completedCount(counts.get("S"))
|
.completedCount(counts.get("S"))
|
||||||
.lastSyncTime(lastSyncTime)
|
.lastSyncTime(lastSyncTime[0])
|
||||||
.stuck(stuck)
|
.stuck(stuck)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user