perf: 동기화 현황 데이터 로딩 속도 개선 (#9) #17

병합
HYOJIN feature/ISSUE-9-sync-status-performance 에서 develop 로 2 commits 를 머지했습니다 2026-03-25 15:02:46 +09:00
9개의 변경된 파일275개의 추가작업 그리고 41개의 파일을 삭제

파일 보기

@ -11,6 +11,8 @@
- abandon/stale 실행 관리 엔드포인트 구현 (#7) - abandon/stale 실행 관리 엔드포인트 구현 (#7)
### 변경 ### 변경
- 동기화 현황 데이터 로딩 속도 개선: 쿼리 합침 + Caffeine 캐시(10분 TTL) + 인덱스 DDL (#9)
- 30초 폴링 제거 → 수동 새로고침 방식으로 변경
- 동기화 현황 노출 테이블 목록 정리: ship-001/ship-002 제거, source-target 키 매핑 정리 (#11) - 동기화 현황 노출 테이블 목록 정리: ship-001/ship-002 제거, source-target 키 매핑 정리 (#11)
- BaseSyncReader 추출: 49개 Reader 공통 로직 통합, 1 chunk = 1 job_execution_id 보장 - BaseSyncReader 추출: 49개 Reader 공통 로직 통합, 1 chunk = 1 job_execution_id 보장
- chunk 경계 제어를 GroupByExecutionIdPolicy에서 Reader 자체 제어로 변경 - chunk 경계 제어를 GroupByExecutionIdPolicy에서 Reader 자체 제어로 변경

파일 보기

@ -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,7 +111,21 @@ 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>
<HelpButton onClick={() => setGuideOpen(true)} /> <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)} />
</div>
</div> </div>
{/* ── Domain Tabs ── */} {/* ── Domain Tabs ── */}

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>

파일 보기

@ -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,28 +274,17 @@ 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");
if (lastSync != null) {
lastSyncTime[0] = lastSync;
}
}); });
// 최근 동기화 시간 (COMPLETED된 job의 batch_flag='S' 가장 최근 mdfcn_dt)
String lastSyncSql = """
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;
return SyncTableStatus.builder() return SyncTableStatus.builder()
@ -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();
} }