API 통계 화면 구현

This commit is contained in:
daekuk1
2026-01-26 14:07:50 +09:00
parent ef50801594
commit 75fb8234f6
30 changed files with 4708 additions and 103 deletions
+5
View File
@@ -152,4 +152,9 @@
<bean class="com.eactive.ext.kjb.web.ApiTransactionLogController"></bean> <bean class="com.eactive.ext.kjb.web.ApiTransactionLogController"></bean>
<bean class="com.eactive.ext.kjb.statistics.ApiStatsMinuteController"></bean> <bean class="com.eactive.ext.kjb.statistics.ApiStatsMinuteController"></bean>
<bean class="com.eactive.ext.kjb.statistics.ApiStatsHourController"></bean> <bean class="com.eactive.ext.kjb.statistics.ApiStatsHourController"></bean>
<bean class="com.eactive.ext.kjb.statistics.ApiStatsDayController" />
<bean class="com.eactive.ext.kjb.statistics.ApiStatsMonthController" />
<bean class="com.eactive.ext.kjb.statistics.ApiStatsYearController" />
<bean class="com.eactive.ext.kjb.statistics.ApiStatsAggregationController" />
</beans> </beans>
@@ -0,0 +1,353 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
request.setCharacterEncoding("UTF-8");
%>
<html>
<head>
<title>API 통계 집계 실행</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<style>
.aggregation-section {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
}
.aggregation-section h3 {
margin: 0 0 15px 0;
padding-bottom: 10px;
border-bottom: 2px solid #4CAF50;
color: #333;
font-size: 16px;
}
.aggregation-form {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.aggregation-form label {
font-weight: bold;
min-width: 80px;
}
.aggregation-form input[type="text"] {
width: 150px;
padding: 5px 10px;
}
.aggregation-form .cssbtn {
min-width: 120px;
}
.result-area {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
display: none;
}
.result-area.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.result-area.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.result-area pre {
margin: 5px 0 0 0;
white-space: pre-wrap;
font-size: 12px;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196F3;
padding: 12px;
margin-bottom: 20px;
}
.info-box ul {
margin: 5px 0 0 20px;
padding: 0;
}
.info-box li {
margin: 3px 0;
}
</style>
<jsp:include page="/jsp/common/include/script.jsp"/>
<script language="javascript">
function executeHourlyToDaily() {
var targetDate = $("#targetDate").val().replace(/-/g, "");
var resultDiv = $("#dailyResult");
// 날짜 입력 검증
if (!targetDate || targetDate.trim() === '') {
alert('대상 날짜를 입력하세요. (예: 20250120)');
$("#targetDate").focus();
return;
}
resultDiv.hide();
$("#btn_execute_daily").prop("disabled", true).text("실행중...");
$.ajax({
url: '<c:url value="/onl/kjb/statistics/apiStatsAggregationMan.json"/>',
type: 'POST',
data: {
cmd: 'AGGREGATION_DAY',
targetDate: targetDate,
serviceType: '${param.serviceType}'
},
success: function(response) {
resultDiv.removeClass("error").addClass("success");
var message = response.message + "\n";
message += "대상 날짜: " + response.targetDate + "\n";
message += "처리 건수: " + response.processedCount;
resultDiv.find("pre").text(message);
resultDiv.show();
},
error: function(xhr) {
resultDiv.removeClass("success").addClass("error");
var message = "집계 실행 실패\n";
try {
var error = JSON.parse(xhr.responseText);
message += error.message;
} catch(e) {
message += xhr.responseText || "알 수 없는 오류가 발생했습니다.";
}
resultDiv.find("pre").text(message);
resultDiv.show();
},
complete: function() {
$("#btn_execute_daily").prop("disabled", false).text("집계 실행");
}
});
}
function executeDailyToMonthly() {
var targetMonth = $("#targetMonth").val().replace(/-/g, "");
var resultDiv = $("#monthlyResult");
// 월 입력 검증
if (!targetMonth || targetMonth.trim() === '') {
alert('대상 월을 입력하세요. (예: 202501)');
$("#targetMonth").focus();
return;
}
resultDiv.hide();
$("#btn_execute_monthly").prop("disabled", true).text("실행중...");
$.ajax({
url: '<c:url value="/onl/kjb/statistics/apiStatsAggregationMan.json"/>',
type: 'POST',
data: {
cmd: 'AGGREGATION_MONTH',
targetMonth: targetMonth,
serviceType: '${param.serviceType}'
},
success: function(response) {
resultDiv.removeClass("error").addClass("success");
var message = response.message + "\n";
message += "대상 월: " + response.targetMonth + "\n";
message += "처리 건수: " + response.processedCount;
resultDiv.find("pre").text(message);
resultDiv.show();
},
error: function(xhr) {
resultDiv.removeClass("success").addClass("error");
var message = "집계 실행 실패\n";
try {
var error = JSON.parse(xhr.responseText);
message += error.message;
} catch(e) {
message += xhr.responseText || "알 수 없는 오류가 발생했습니다.";
}
resultDiv.find("pre").text(message);
resultDiv.show();
},
complete: function() {
$("#btn_execute_monthly").prop("disabled", false).text("집계 실행");
}
});
}
function executeMonthlyToYearly() {
var targetYear = $("#targetYear").val();
var resultDiv = $("#yearlyResult");
// 연도 입력 검증
if (!targetYear || targetYear.trim() === '') {
alert('대상 연도를 입력하세요. (예: 2024)');
$("#targetYear").focus();
return;
}
resultDiv.hide();
$("#btn_execute_yearly").prop("disabled", true).text("실행중...");
$.ajax({
url: '<c:url value="/onl/kjb/statistics/apiStatsAggregationMan.json"/>',
type: 'POST',
data: {
cmd: 'AGGREGATION_YEAR',
targetYear: targetYear,
serviceType: '${param.serviceType}'
},
success: function(response) {
resultDiv.removeClass("error").addClass("success");
var message = response.message + "\n";
message += "대상 연도: " + response.targetYear + "\n";
message += "처리 건수: " + response.processedCount;
resultDiv.find("pre").text(message);
resultDiv.show();
},
error: function(xhr) {
resultDiv.removeClass("success").addClass("error");
var message = "집계 실행 실패\n";
try {
var error = JSON.parse(xhr.responseText);
message += error.message;
} catch(e) {
message += xhr.responseText || "알 수 없는 오류가 발생했습니다.";
}
resultDiv.find("pre").text(message);
resultDiv.show();
},
complete: function() {
$("#btn_execute_yearly").prop("disabled", false).text("집계 실행");
}
});
}
$(document).ready(function() {
// 날짜 입력 마스크
$("#targetDate").inputmask("99999999", { 'autoUnmask': true, 'placeholder': '' });
$("#targetMonth").inputmask("999999", { 'autoUnmask': true, 'placeholder': '' });
$("#targetYear").inputmask("9999", { 'autoUnmask': true, 'placeholder': '' });
// 초기값 설정 (전일, 전월, 전년)
var today = new Date();
// 전일
var yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
var year = yesterday.getFullYear();
var month = String(yesterday.getMonth() + 1).padStart(2, '0');
var day = String(yesterday.getDate()).padStart(2, '0');
$("#targetDate").val(year + month + day);
// 전월
var lastMonth = new Date(today);
lastMonth.setMonth(lastMonth.getMonth() - 1);
var lastMonthYear = lastMonth.getFullYear();
var lastMonthMonth = String(lastMonth.getMonth() + 1).padStart(2, '0');
$("#targetMonth").val(lastMonthYear + lastMonthMonth);
// 전년
var lastYear = today.getFullYear() - 1;
$("#targetYear").val(lastYear);
// 버튼 이벤트
$("#btn_execute_daily").click(function() {
if (confirm("시간별→일별 통계 집계를 실행하시겠습니까?")) {
executeHourlyToDaily();
}
});
$("#btn_execute_monthly").click(function() {
if (confirm("일별→월별 통계 집계를 실행하시겠습니까?")) {
executeDailyToMonthly();
}
});
$("#btn_execute_yearly").click(function() {
if (confirm("월별→연별 통계 집계를 실행하시겠습니까?")) {
executeMonthlyToYearly();
}
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div>
<div class="content_middle" id="content_middle">
<div class="title" id="title">API 통계 집계 수동 실행</div>
<div class="info-box">
<strong>⚠️ 주의사항</strong>
<ul>
<li>통계 집계는 스케줄러에 의해 자동으로 실행됩니다.</li>
<li>수동 실행은 누락된 기간이나 재집계가 필요한 경우에만 사용하세요.</li>
<li>집계 대상 기간의 원본 데이터가 존재해야 합니다.</li>
<li>대상 날짜/월/연도를 반드시 입력해야 합니다.</li>
</ul>
</div>
<!-- 시간별 → 일별 집계 -->
<div class="aggregation-section">
<h3>1. 시간별 → 일별 통계 집계</h3>
<div class="aggregation-form">
<label>대상 날짜:</label>
<input type="text" id="targetDate" placeholder="yyyyMMdd" maxlength="8">
<span style="color: #666; font-size: 12px;">(예: 20250120)</span>
<button type="button" class="cssbtn" id="btn_execute_daily" level="W">
<i class="material-icons">play_arrow</i> 집계 실행
</button>
</div>
<div id="dailyResult" class="result-area">
<strong>실행 결과:</strong>
<pre></pre>
</div>
</div>
<!-- 일별 → 월별 집계 -->
<div class="aggregation-section">
<h3>2. 일별 → 월별 통계 집계</h3>
<div class="aggregation-form">
<label>대상 월:</label>
<input type="text" id="targetMonth" placeholder="yyyyMM" maxlength="6">
<span style="color: #666; font-size: 12px;">(예: 202501)</span>
<button type="button" class="cssbtn" id="btn_execute_monthly" level="W">
<i class="material-icons">play_arrow</i> 집계 실행
</button>
</div>
<div id="monthlyResult" class="result-area">
<strong>실행 결과:</strong>
<pre></pre>
</div>
</div>
<!-- 월별 → 연별 집계 -->
<div class="aggregation-section">
<h3>3. 월별 → 연별 통계 집계</h3>
<div class="aggregation-form">
<label>대상 연도:</label>
<input type="text" id="targetYear" placeholder="yyyy" maxlength="4">
<span style="color: #666; font-size: 12px;">(예: 2024)</span>
<button type="button" class="cssbtn" id="btn_execute_yearly" level="W">
<i class="material-icons">play_arrow</i> 집계 실행
</button>
</div>
<div id="yearlyResult" class="result-area">
<strong>실행 결과:</strong>
<pre></pre>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,695 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
request.setCharacterEncoding("UTF-8");
%>
<html>
<head>
<title>일별 통계 조회</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<style>
.chart-container {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
gap: 10px;
}
.chart-container.full-width {
display: block;
}
.chart {
width: 49%;
height: 300px;
border: 1px solid #ddd;
background: #fff;
}
.chart.full-width {
width: 100%;
height: 250px;
}
</style>
<jsp:include page="/jsp/common/include/script.jsp"/>
<script src="<c:url value="/addon/echarts/echarts.min.js"/>"></script>
<script language="javascript">
var url = '<c:url value="/onl/kjb/statistics/apiStatsDayMan.json"/>';
var url_view = '<c:url value="/onl/kjb/statistics/apiStatsDayMan.view"/>';
var totalDonutChart, seq900DonutChart, callChart, respChart;
function numberFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0';
return Number(cellvalue).toLocaleString();
}
function decimalFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0.000';
return Number(cellvalue).toFixed(3);
}
function initCharts() {
totalDonutChart = echarts.init(document.getElementById('totalDonutChart'));
seq900DonutChart = echarts.init(document.getElementById('seq900DonutChart'));
callChart = echarts.init(document.getElementById('callChart'));
respChart = echarts.init(document.getElementById('respChart'));
// 총건수 도넛 차트
var totalDonutOption = {
title: {
text: '총 건수 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['성공', 'Timeout', '시스템오류', '업무오류']
},
series: [{
name: '총건수',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: '성공', itemStyle: { color: '#91CC75' } },
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
totalDonutChart.setOption(totalDonutOption);
// Seq900 도넛 차트
var seq900DonutOption = {
title: {
text: 'Seq900 에러 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['Timeout', '시스템오류', '업무오류']
},
series: [{
name: 'Seq900 에러',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
seq900DonutChart.setOption(seq900DonutOption);
// 호출량 차트
var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['성공', 'Timeout', '시스템오류', '업무오류'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '성공', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#5470C6' }, data: [] },
{ name: 'Timeout', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FAC858' }, data: [] },
{ name: '시스템오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#EE6666' }, data: [] },
{ name: '업무오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FC8452' }, data: [] }
]
};
callChart.setOption(callOption);
// 응답시간 차트
var respOption = {
title: { text: '응답시간 추이 (ms)', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['P95', 'P50', '평균'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value' },
series: [
{ name: 'P95', type: 'line', smooth: true, itemStyle: { color: '#EE6666' }, lineStyle: { type: 'dashed' }, data: [] },
{ name: 'P50', type: 'line', smooth: true, itemStyle: { color: '#5470C6' }, data: [] },
{ name: '평균', type: 'line', smooth: true, itemStyle: { color: '#91CC75' }, data: [] }
]
};
respChart.setOption(respOption);
}
function updateCharts(data) {
// 시계열 차트용 변수
var times = [];
var successData = [];
var timeoutData = [];
var systemErrData = [];
var bizErrData = [];
var avgRespData = [];
var p50RespData = [];
var p95RespData = [];
// 도넛 차트용 총계 변수
var totalSuccess = 0;
var totalTimeout = 0;
var totalSystemErr = 0;
var totalBizErr = 0;
var seq900Timeout = 0;
var seq900SystemErr = 0;
var seq900BizErr = 0;
data.forEach(function(item) {
times.push(item.statTime);
// 시계열 차트 데이터
successData.push(item.successCnt);
timeoutData.push(item.timeoutCnt);
systemErrData.push(item.systemErrCnt);
bizErrData.push(item.bizErrCnt);
avgRespData.push(item.avgRespTime);
p50RespData.push(item.p50RespTime);
p95RespData.push(item.p95RespTime);
// 도넛 차트용 합산
totalSuccess += (item.successCnt || 0);
totalTimeout += (item.timeoutCnt || 0);
totalSystemErr += (item.systemErrCnt || 0);
totalBizErr += (item.bizErrCnt || 0);
seq900Timeout += (item.seq900TimeoutCnt || 0);
seq900SystemErr += (item.seq900SystemErrCnt || 0);
seq900BizErr += (item.seq900BizErrCnt || 0);
});
// 총건수 도넛 차트 업데이트
var grandTotal = totalSuccess + totalTimeout + totalSystemErr + totalBizErr;
totalDonutChart.setOption({
series: [{
data: [
{ value: totalSuccess, name: '성공' },
{ value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' },
{ value: totalBizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}]
});
// Seq900 도넛 차트 업데이트
var seq900Total = seq900Timeout + seq900SystemErr + seq900BizErr;
seq900DonutChart.setOption({
series: [{
data: [
{ value: seq900Timeout, name: 'Timeout' },
{ value: seq900SystemErr, name: '시스템오류' },
{ value: seq900BizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: seq900Total.toLocaleString()
}
}]
});
// 호출량 차트 업데이트
callChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(6, 8) + '일'; }) },
series: [
{ data: successData },
{ data: timeoutData },
{ data: systemErrData },
{ data: bizErrData }
]
});
// 응답시간 차트 업데이트
respChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(6, 8) + '일'; }) },
series: [
{ data: p95RespData },
{ data: p50RespData },
{ data: avgRespData }
]
});
// 드릴다운을 위해 원본 데이터 저장
callChart.rawData = data;
respChart.rawData = data;
}
function fetchChartData() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
if (searchStartDate && searchEndDate) {
var start = new Date(searchStartDate.substring(0, 4), parseInt(searchStartDate.substring(4, 6)) - 1, searchStartDate.substring(6, 8));
var end = new Date(searchEndDate.substring(0, 4), parseInt(searchEndDate.substring(4, 6)) - 1, searchEndDate.substring(6, 8));
var diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > 31) {
alert('조회 기간은 최대 31일까지 가능합니다.');
return;
}
}
var postData = {
cmd: 'CHART',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val()
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$.ajax({
url: url,
type: 'POST',
data: postData,
success: function(response) {
updateCharts(response);
},
error: function(xhr, status, error) {
console.error('Chart data fetch error:', error);
}
});
}
function search() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
if (searchStartDate && searchEndDate) {
var start = new Date(searchStartDate.substring(0, 4), parseInt(searchStartDate.substring(4, 6)) - 1, searchStartDate.substring(6, 8));
var end = new Date(searchEndDate.substring(0, 4), parseInt(searchEndDate.substring(4, 6)) - 1, searchEndDate.substring(6, 8));
var diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
if (diffDays > 31) {
alert('조회 기간은 최대 31일까지 가능합니다.');
return;
}
}
var postData = getSearchForJqgrid("cmd", "LIST");
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$("#grid").setGridParam({ url: url, postData: postData, page: 1 }).trigger("reloadGrid");
fetchChartData();
}
function exportToExcel() {
console.log('[Excel Export] Starting...');
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
var postData = {
cmd: 'EXCEL_EXPORT',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val(),
serviceType: '${param.serviceType}'
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
console.log('[Excel Export] Request data:', postData);
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = 'blob';
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.onload = function() {
console.log('[Excel Export] Response received - Status:', xhr.status);
if (xhr.status === 200) {
var blob = xhr.response;
var contentType = xhr.getResponseHeader('Content-Type');
console.log('[Excel Export] Blob size:', blob.size, 'Content-Type:', contentType);
// JSON 에러 응답인지 확인
if (contentType && contentType.indexOf('application/json') !== -1) {
var reader = new FileReader();
reader.onload = function() {
try {
var errorObj = JSON.parse(reader.result);
alert(errorObj.message || 'Excel 파일 생성에 실패했습니다.');
} catch (e) {
alert('Excel 파일 생성에 실패했습니다.');
}
};
reader.readAsText(blob);
return;
}
// Blob 크기가 0이면 에러
if (blob.size === 0) {
alert('Excel 파일 생성에 실패했습니다. (빈 응답)');
return;
}
// 파일명 생성
var filename = 'API일별통계_' + searchStartDate + '.xlsx';
var disposition = xhr.getResponseHeader('Content-Disposition');
console.log('[Excel Export] Content-Disposition:', disposition);
if (disposition && disposition.indexOf('filename=') !== -1) {
var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
var matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
filename = decodeURIComponent(filename);
}
}
console.log('[Excel Export] Downloading as:', filename);
// 파일 다운로드
var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
console.log('[Excel Export] Download triggered');
} else if (xhr.status === 204) {
alert('조회된 데이터가 없습니다.');
} else {
// 에러 응답 처리
var reader = new FileReader();
reader.onload = function() {
var message = 'Excel 다운로드 중 오류가 발생했습니다.';
try {
var errorObj = JSON.parse(reader.result);
if (errorObj.message) message = errorObj.message;
} catch (e) {
console.error('[Excel Export] Error parsing error response:', e);
}
alert(message);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = function() {
console.error('[Excel Export] Network error');
alert('네트워크 오류가 발생했습니다.');
};
// Form data 생성
var formData = [];
for (var key in postData) {
if (postData.hasOwnProperty(key) && postData[key] != null && postData[key] !== '') {
formData.push(encodeURIComponent(key) + '=' + encodeURIComponent(postData[key]));
}
}
xhr.send(formData.join('&'));
}
function list() {
var gridPostData = getSearchForJqgrid("cmd", "LIST");
$('#grid').jqGrid({
datatype: "json",
mtype: 'POST',
postData: gridPostData,
colNames: [
'통계시간', 'API명', '인스턴스', '업무구분', '클라이언트ID',
'Inbound Adapter', 'Outbound Adapter',
'총건수', '성공', 'Timeout', '시스템오류', '업무오류',
'Seq900 Timeout', 'Seq900 시스템오류', 'Seq900 업무오류',
'평균응답(ms)', '최소응답(ms)', '최대응답(ms)', 'P50(ms)', 'P95(ms)'
],
colModel: [
{ name: 'statTime', align: 'center', width: '120', sortable: false },
{ name: 'apiName', align: 'left', width: '150', sortable: false },
{ name: 'gwInstanceId', align: 'center', width: '80', sortable: false },
{ name: 'bizDivCode', align: 'center', width: '80', sortable: false },
{ name: 'clientId', align: 'left', width: '100', sortable: false },
{ name: 'inboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'outboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'totalCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'successCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'timeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'systemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'bizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900TimeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900SystemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900BizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'avgRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'minRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'maxRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'p50RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false },
{ name: 'p95RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false }
],
jsonReader: { repeatitems: false },
pager: $('#pager'),
page: '${param.page}',
rowNum: '${rmsDefaultRowNum}',
autoheight: true,
height: $("#container").height(),
autowidth: true,
viewrecords: true,
rowList: eval('[${rmsDefaultRowList}]'),
loadComplete: function(d) {
var colModel = $(this).getGridParam("colModel");
for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false });
}
}
});
}
$(document).ready(function() {
// 날짜 입력 마스크
$("input[name=searchStartDateTime]").inputmask("9999-99-99", { 'autoUnmask': true });
$("input[name=searchEndDateTime]").inputmask("9999-99-99", { 'autoUnmask': true });
// 기본값 설정 (이번달 1일 ~ 오늘)
var today = getToday();
if (!$("input[name=searchStartDateTime]").val()) {
var firstDay = today.substring(0, 8) + "01";
$("input[name=searchStartDateTime]").val(firstDay.substring(0, 4) + '-' + firstDay.substring(4, 6) + '-' + firstDay.substring(6, 8));
}
if (!$("input[name=searchEndDateTime]").val()) {
$("input[name=searchEndDateTime]").val(today);
}
initCharts();
list();
search();
fetchChartData();
resizeJqGridWidth('grid', 'content_middle', '1200');
$("#btn_search").click(function() {
search();
});
$("#btn_excel_export").click(function() {
exportToExcel();
});
$("input[name^=search]").keydown(function(key) {
if (key.keyCode == 13) {
$("#btn_search").click();
}
});
// 윈도우 리사이즈 시 차트 리사이즈
$(window).resize(function() {
if (totalDonutChart) totalDonutChart.resize();
if (seq900DonutChart) seq900DonutChart.resize();
if (callChart) callChart.resize();
if (respChart) respChart.resize();
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div>
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R">
<i class="material-icons">search</i> <%= localeMessage.getString("button.search") %>
</button>
<button type="button" class="cssbtn" id="btn_excel_export" level="R">
<i class="material-icons">file_download</i> Excel 다운로드
</button>
</div>
<div class="title" id="title">API 일별 통계</div>
<table class="search_condition" cellspacing="0">
<tbody>
<tr>
<th style="width:100px;">조회기간</th>
<td colspan="3">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;">
~
<input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;">
<span style="color:#888; font-size:12px; margin-left:10px;">(최대 31일)</span>
</td>
</tr>
<tr>
<th style="width:100px;">API명</th>
<td>
<input type="text" name="searchApiName" value="${param.searchApiName}">
</td>
<th style="width:100px;">인스턴스</th>
<td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td>
</tr>
<tr>
<th style="width:100px;">업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td>
<input type="text" name="searchClientId" value="${param.searchClientId}">
</td>
</tr>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td>
<th style="width:100px;">Outbound Adapter</th>
<td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td>
</tr>
</tbody>
</table>
<!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container">
<div id="totalDonutChart" class="chart"></div>
<div id="seq900DonutChart" class="chart"></div>
</div>
<!-- 호출량 및 응답시간 차트 (하단 50%씩) -->
<div class="chart-container">
<div id="callChart" class="chart"></div>
<div id="respChart" class="chart"></div>
</div>
<table id="grid"></table>
<div id="pager"></div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,702 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
request.setCharacterEncoding("UTF-8");
%>
<html>
<head>
<title>월별 통계 조회</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<style>
.chart-container {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
gap: 10px;
}
.chart-container.full-width {
display: block;
}
.chart {
width: 49%;
height: 300px;
border: 1px solid #ddd;
background: #fff;
}
.chart.full-width {
width: 100%;
height: 250px;
}
</style>
<jsp:include page="/jsp/common/include/script.jsp"/>
<script src="<c:url value="/addon/echarts/echarts.min.js"/>"></script>
<script language="javascript">
var url = '<c:url value="/onl/kjb/statistics/apiStatsMonthMan.json"/>';
var url_view = '<c:url value="/onl/kjb/statistics/apiStatsMonthMan.view"/>';
var totalDonutChart, seq900DonutChart, callChart, respChart;
function numberFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0';
return Number(cellvalue).toLocaleString();
}
function decimalFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0.000';
return Number(cellvalue).toFixed(3);
}
function initCharts() {
totalDonutChart = echarts.init(document.getElementById('totalDonutChart'));
seq900DonutChart = echarts.init(document.getElementById('seq900DonutChart'));
callChart = echarts.init(document.getElementById('callChart'));
respChart = echarts.init(document.getElementById('respChart'));
// 총건수 도넛 차트
var totalDonutOption = {
title: {
text: '총 건수 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['성공', 'Timeout', '시스템오류', '업무오류']
},
series: [{
name: '총건수',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: '성공', itemStyle: { color: '#91CC75' } },
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
totalDonutChart.setOption(totalDonutOption);
// Seq900 도넛 차트
var seq900DonutOption = {
title: {
text: 'Seq900 에러 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['Timeout', '시스템오류', '업무오류']
},
series: [{
name: 'Seq900 에러',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
seq900DonutChart.setOption(seq900DonutOption);
// 호출량 차트
var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['성공', 'Timeout', '시스템오류', '업무오류'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '성공', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#5470C6' }, data: [] },
{ name: 'Timeout', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FAC858' }, data: [] },
{ name: '시스템오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#EE6666' }, data: [] },
{ name: '업무오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FC8452' }, data: [] }
]
};
callChart.setOption(callOption);
// 응답시간 차트
var respOption = {
title: { text: '응답시간 추이 (ms)', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['P95', 'P50', '평균'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value' },
series: [
{ name: 'P95', type: 'line', smooth: true, itemStyle: { color: '#EE6666' }, lineStyle: { type: 'dashed' }, data: [] },
{ name: 'P50', type: 'line', smooth: true, itemStyle: { color: '#5470C6' }, data: [] },
{ name: '평균', type: 'line', smooth: true, itemStyle: { color: '#91CC75' }, data: [] }
]
};
respChart.setOption(respOption);
}
function updateCharts(data) {
// 시계열 차트용 변수
var times = [];
var successData = [];
var timeoutData = [];
var systemErrData = [];
var bizErrData = [];
var avgRespData = [];
var p50RespData = [];
var p95RespData = [];
// 도넛 차트용 총계 변수
var totalSuccess = 0;
var totalTimeout = 0;
var totalSystemErr = 0;
var totalBizErr = 0;
var seq900Timeout = 0;
var seq900SystemErr = 0;
var seq900BizErr = 0;
data.forEach(function(item) {
times.push(item.statTime);
// 시계열 차트 데이터
successData.push(item.successCnt);
timeoutData.push(item.timeoutCnt);
systemErrData.push(item.systemErrCnt);
bizErrData.push(item.bizErrCnt);
avgRespData.push(item.avgRespTime);
p50RespData.push(item.p50RespTime);
p95RespData.push(item.p95RespTime);
// 도넛 차트용 합산
totalSuccess += (item.successCnt || 0);
totalTimeout += (item.timeoutCnt || 0);
totalSystemErr += (item.systemErrCnt || 0);
totalBizErr += (item.bizErrCnt || 0);
seq900Timeout += (item.seq900TimeoutCnt || 0);
seq900SystemErr += (item.seq900SystemErrCnt || 0);
seq900BizErr += (item.seq900BizErrCnt || 0);
});
// 총건수 도넛 차트 업데이트
var grandTotal = totalSuccess + totalTimeout + totalSystemErr + totalBizErr;
totalDonutChart.setOption({
series: [{
data: [
{ value: totalSuccess, name: '성공' },
{ value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' },
{ value: totalBizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}]
});
// Seq900 도넛 차트 업데이트
var seq900Total = seq900Timeout + seq900SystemErr + seq900BizErr;
seq900DonutChart.setOption({
series: [{
data: [
{ value: seq900Timeout, name: 'Timeout' },
{ value: seq900SystemErr, name: '시스템오류' },
{ value: seq900BizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: seq900Total.toLocaleString()
}
}]
});
// 호출량 차트 업데이트
callChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(4, 6) + '월'; }) },
series: [
{ data: successData },
{ data: timeoutData },
{ data: systemErrData },
{ data: bizErrData }
]
});
// 응답시간 차트 업데이트
respChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(4, 6) + '월'; }) },
series: [
{ data: p95RespData },
{ data: p50RespData },
{ data: avgRespData }
]
});
// 드릴다운을 위해 원본 데이터 저장
callChart.rawData = data;
respChart.rawData = data;
}
function fetchChartData() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
if (searchStartDate && searchEndDate) {
var startYear = parseInt(searchStartDate.substring(0, 4));
var startMonth = parseInt(searchStartDate.substring(4, 6));
var endYear = parseInt(searchEndDate.substring(0, 4));
var endMonth = parseInt(searchEndDate.substring(4, 6));
var diffMonths = (endYear - startYear) * 12 + (endMonth - startMonth);
if (diffMonths > 24) {
alert('조회 기간은 최대 24개월까지 가능합니다.');
return;
}
}
var postData = {
cmd: 'CHART',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val()
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$.ajax({
url: url,
type: 'POST',
data: postData,
success: function(response) {
updateCharts(response);
},
error: function(xhr, status, error) {
console.error('Chart data fetch error:', error);
}
});
}
function search() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
if (searchStartDate && searchEndDate) {
var startYear = parseInt(searchStartDate.substring(0, 4));
var startMonth = parseInt(searchStartDate.substring(4, 6));
var endYear = parseInt(searchEndDate.substring(0, 4));
var endMonth = parseInt(searchEndDate.substring(4, 6));
var diffMonths = (endYear - startYear) * 12 + (endMonth - startMonth);
if (diffMonths > 24) {
alert('조회 기간은 최대 24개월까지 가능합니다.');
return;
}
}
var postData = getSearchForJqgrid("cmd", "LIST");
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$("#grid").setGridParam({ url: url, postData: postData, page: 1 }).trigger("reloadGrid");
fetchChartData();
}
function exportToExcel() {
console.log('[Excel Export] Starting...');
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
var postData = {
cmd: 'EXCEL_EXPORT',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val(),
serviceType: '${param.serviceType}'
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
console.log('[Excel Export] Request data:', postData);
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = 'blob';
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.onload = function() {
console.log('[Excel Export] Response received - Status:', xhr.status);
if (xhr.status === 200) {
var blob = xhr.response;
var contentType = xhr.getResponseHeader('Content-Type');
console.log('[Excel Export] Blob size:', blob.size, 'Content-Type:', contentType);
// JSON 에러 응답인지 확인
if (contentType && contentType.indexOf('application/json') !== -1) {
var reader = new FileReader();
reader.onload = function() {
try {
var errorObj = JSON.parse(reader.result);
alert(errorObj.message || 'Excel 파일 생성에 실패했습니다.');
} catch (e) {
alert('Excel 파일 생성에 실패했습니다.');
}
};
reader.readAsText(blob);
return;
}
// Blob 크기가 0이면 에러
if (blob.size === 0) {
alert('Excel 파일 생성에 실패했습니다. (빈 응답)');
return;
}
// 파일명 생성
var filename = 'API월별통계_' + searchStartDate + '.xlsx';
var disposition = xhr.getResponseHeader('Content-Disposition');
console.log('[Excel Export] Content-Disposition:', disposition);
if (disposition && disposition.indexOf('filename=') !== -1) {
var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
var matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
filename = decodeURIComponent(filename);
}
}
console.log('[Excel Export] Downloading as:', filename);
// 파일 다운로드
var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
console.log('[Excel Export] Download triggered');
} else if (xhr.status === 204) {
alert('조회된 데이터가 없습니다.');
} else {
// 에러 응답 처리
var reader = new FileReader();
reader.onload = function() {
var message = 'Excel 다운로드 중 오류가 발생했습니다.';
try {
var errorObj = JSON.parse(reader.result);
if (errorObj.message) message = errorObj.message;
} catch (e) {
console.error('[Excel Export] Error parsing error response:', e);
}
alert(message);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = function() {
console.error('[Excel Export] Network error');
alert('네트워크 오류가 발생했습니다.');
};
// Form data 생성
var formData = [];
for (var key in postData) {
if (postData.hasOwnProperty(key) && postData[key] != null && postData[key] !== '') {
formData.push(encodeURIComponent(key) + '=' + encodeURIComponent(postData[key]));
}
}
xhr.send(formData.join('&'));
}
function list() {
var gridPostData = getSearchForJqgrid("cmd", "LIST");
$('#grid').jqGrid({
datatype: "json",
mtype: 'POST',
postData: gridPostData,
colNames: [
'통계시간', 'API명', '인스턴스', '업무구분', '클라이언트ID',
'Inbound Adapter', 'Outbound Adapter',
'총건수', '성공', 'Timeout', '시스템오류', '업무오류',
'Seq900 Timeout', 'Seq900 시스템오류', 'Seq900 업무오류',
'평균응답(ms)', '최소응답(ms)', '최대응답(ms)', 'P50(ms)', 'P95(ms)'
],
colModel: [
{ name: 'statTime', align: 'center', width: '120', sortable: false },
{ name: 'apiName', align: 'left', width: '150', sortable: false },
{ name: 'gwInstanceId', align: 'center', width: '80', sortable: false },
{ name: 'bizDivCode', align: 'center', width: '80', sortable: false },
{ name: 'clientId', align: 'left', width: '100', sortable: false },
{ name: 'inboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'outboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'totalCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'successCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'timeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'systemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'bizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900TimeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900SystemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900BizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'avgRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'minRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'maxRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'p50RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false },
{ name: 'p95RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false }
],
jsonReader: { repeatitems: false },
pager: $('#pager'),
page: '${param.page}',
rowNum: '${rmsDefaultRowNum}',
autoheight: true,
height: $("#container").height(),
autowidth: true,
viewrecords: true,
rowList: eval('[${rmsDefaultRowList}]'),
loadComplete: function(d) {
var colModel = $(this).getGridParam("colModel");
for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false });
}
}
});
}
$(document).ready(function() {
// 월 입력 마스크
$("input[name=searchStartDateTime]").inputmask("9999-99", { 'autoUnmask': true });
$("input[name=searchEndDateTime]").inputmask("9999-99", { 'autoUnmask': true });
// 기본값 설정 (최근 12개월)
var today = new Date();
var endMonth = today.getFullYear() + '-' + (today.getMonth() + 1 < 10 ? '0' : '') + (today.getMonth() + 1);
var startDate = new Date(today.getFullYear(), today.getMonth() - 11, 1);
var startMonth = startDate.getFullYear() + '-' + (startDate.getMonth() + 1 < 10 ? '0' : '') + (startDate.getMonth() + 1);
if (!$("input[name=searchStartDateTime]").val()) {
$("input[name=searchStartDateTime]").val(startMonth);
}
if (!$("input[name=searchEndDateTime]").val()) {
$("input[name=searchEndDateTime]").val(endMonth);
}
initCharts();
list();
search();
fetchChartData();
resizeJqGridWidth('grid', 'content_middle', '1200');
$("#btn_search").click(function() {
search();
});
$("#btn_excel_export").click(function() {
exportToExcel();
});
$("input[name^=search]").keydown(function(key) {
if (key.keyCode == 13) {
$("#btn_search").click();
}
});
// 윈도우 리사이즈 시 차트 리사이즈
$(window).resize(function() {
if (totalDonutChart) totalDonutChart.resize();
if (seq900DonutChart) seq900DonutChart.resize();
if (callChart) callChart.resize();
if (respChart) respChart.resize();
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div>
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R">
<i class="material-icons">search</i> <%= localeMessage.getString("button.search") %>
</button>
<button type="button" class="cssbtn" id="btn_excel_export" level="R">
<i class="material-icons">file_download</i> Excel 다운로드
</button>
</div>
<div class="title" id="title">API 월별 통계</div>
<table class="search_condition" cellspacing="0">
<tbody>
<tr>
<th style="width:100px;">조회기간</th>
<td colspan="3">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY-MM">
~
<input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY-MM">
<span style="color:#888; font-size:12px; margin-left:10px;">(최대 24개월)</span>
</td>
</tr>
<tr>
<th style="width:100px;">API명</th>
<td>
<input type="text" name="searchApiName" value="${param.searchApiName}">
</td>
<th style="width:100px;">인스턴스</th>
<td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td>
</tr>
<tr>
<th style="width:100px;">업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td>
<input type="text" name="searchClientId" value="${param.searchClientId}">
</td>
</tr>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td>
<th style="width:100px;">Outbound Adapter</th>
<td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td>
</tr>
</tbody>
</table>
<!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container">
<div id="totalDonutChart" class="chart"></div>
<div id="seq900DonutChart" class="chart"></div>
</div>
<!-- 호출량 및 응답시간 차트 (하단 50%씩) -->
<div class="chart-container">
<div id="callChart" class="chart"></div>
<div id="respChart" class="chart"></div>
</div>
<table id="grid"></table>
<div id="pager"></div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,670 @@
<%@ page language="java" contentType="text/html; charset=utf-8"%>
<%@ page import="java.io.*"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ include file="/jsp/common/include/localemessage.jsp" %>
<%
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Expires", "0");
request.setCharacterEncoding("UTF-8");
%>
<html>
<head>
<title>연간 통계 조회</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<jsp:include page="/jsp/common/include/css.jsp"/>
<style>
.chart-container {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
gap: 10px;
}
.chart-container.full-width {
display: block;
}
.chart {
width: 49%;
height: 300px;
border: 1px solid #ddd;
background: #fff;
}
.chart.full-width {
width: 100%;
height: 250px;
}
</style>
<jsp:include page="/jsp/common/include/script.jsp"/>
<script src="<c:url value="/addon/echarts/echarts.min.js"/>"></script>
<script language="javascript">
var url = '<c:url value="/onl/kjb/statistics/apiStatsYearMan.json"/>';
var url_view = '<c:url value="/onl/kjb/statistics/apiStatsYearMan.view"/>';
var totalDonutChart, seq900DonutChart, callChart, respChart;
function numberFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0';
return Number(cellvalue).toLocaleString();
}
function decimalFormatter(cellvalue, options, rowObject) {
if (cellvalue == null || cellvalue == '') return '0.000';
return Number(cellvalue).toFixed(3);
}
function initCharts() {
totalDonutChart = echarts.init(document.getElementById('totalDonutChart'));
seq900DonutChart = echarts.init(document.getElementById('seq900DonutChart'));
callChart = echarts.init(document.getElementById('callChart'));
respChart = echarts.init(document.getElementById('respChart'));
// 총건수 도넛 차트
var totalDonutOption = {
title: {
text: '총 건수 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['성공', 'Timeout', '시스템오류', '업무오류']
},
series: [{
name: '총건수',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: '성공', itemStyle: { color: '#91CC75' } },
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
totalDonutChart.setOption(totalDonutOption);
// Seq900 도넛 차트
var seq900DonutOption = {
title: {
text: 'Seq900 에러 분포',
left: 'center',
top: 10,
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'horizontal',
bottom: 10,
data: ['Timeout', '시스템오류', '업무오류']
},
series: [{
name: 'Seq900 에러',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
label: {
show: true,
formatter: '{b}: {c}'
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
labelLine: {
show: true
},
data: [
{ value: 0, name: 'Timeout', itemStyle: { color: '#FAC858' } },
{ value: 0, name: '시스템오류', itemStyle: { color: '#EE6666' } },
{ value: 0, name: '업무오류', itemStyle: { color: '#FC8452' } }
]
}],
graphic: [{
type: 'text',
left: 'center',
top: 'center',
style: {
text: '0',
textAlign: 'center',
fill: '#333',
fontSize: 24,
fontWeight: 'bold'
}
}]
};
seq900DonutChart.setOption(seq900DonutOption);
// 호출량 차트
var callOption = {
title: { text: '호출량 추이', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['성공', 'Timeout', '시스템오류', '업무오류'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '성공', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#5470C6' }, data: [] },
{ name: 'Timeout', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FAC858' }, data: [] },
{ name: '시스템오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#EE6666' }, data: [] },
{ name: '업무오류', type: 'line', stack: 'Total', smooth: true, areaStyle: { opacity: 0.5 }, itemStyle: { color: '#FC8452' }, data: [] }
]
};
callChart.setOption(callOption);
// 응답시간 차트
var respOption = {
title: { text: '응답시간 추이 (ms)', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } }
},
legend: { data: ['P95', 'P50', '평균'], bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value' },
series: [
{ name: 'P95', type: 'line', smooth: true, itemStyle: { color: '#EE6666' }, lineStyle: { type: 'dashed' }, data: [] },
{ name: 'P50', type: 'line', smooth: true, itemStyle: { color: '#5470C6' }, data: [] },
{ name: '평균', type: 'line', smooth: true, itemStyle: { color: '#91CC75' }, data: [] }
]
};
respChart.setOption(respOption);
}
function updateCharts(data) {
// 시계열 차트용 변수
var times = [];
var successData = [];
var timeoutData = [];
var systemErrData = [];
var bizErrData = [];
var avgRespData = [];
var p50RespData = [];
var p95RespData = [];
// 도넛 차트용 총계 변수
var totalSuccess = 0;
var totalTimeout = 0;
var totalSystemErr = 0;
var totalBizErr = 0;
var seq900Timeout = 0;
var seq900SystemErr = 0;
var seq900BizErr = 0;
data.forEach(function(item) {
times.push(item.statTime);
// 시계열 차트 데이터
successData.push(item.successCnt);
timeoutData.push(item.timeoutCnt);
systemErrData.push(item.systemErrCnt);
bizErrData.push(item.bizErrCnt);
avgRespData.push(item.avgRespTime);
p50RespData.push(item.p50RespTime);
p95RespData.push(item.p95RespTime);
// 도넛 차트용 합산
totalSuccess += (item.successCnt || 0);
totalTimeout += (item.timeoutCnt || 0);
totalSystemErr += (item.systemErrCnt || 0);
totalBizErr += (item.bizErrCnt || 0);
seq900Timeout += (item.seq900TimeoutCnt || 0);
seq900SystemErr += (item.seq900SystemErrCnt || 0);
seq900BizErr += (item.seq900BizErrCnt || 0);
});
// 총건수 도넛 차트 업데이트
var grandTotal = totalSuccess + totalTimeout + totalSystemErr + totalBizErr;
totalDonutChart.setOption({
series: [{
data: [
{ value: totalSuccess, name: '성공' },
{ value: totalTimeout, name: 'Timeout' },
{ value: totalSystemErr, name: '시스템오류' },
{ value: totalBizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: grandTotal.toLocaleString()
}
}]
});
// Seq900 도넛 차트 업데이트
var seq900Total = seq900Timeout + seq900SystemErr + seq900BizErr;
seq900DonutChart.setOption({
series: [{
data: [
{ value: seq900Timeout, name: 'Timeout' },
{ value: seq900SystemErr, name: '시스템오류' },
{ value: seq900BizErr, name: '업무오류' }
]
}],
graphic: [{
style: {
text: seq900Total.toLocaleString()
}
}]
});
// 호출량 차트 업데이트
callChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(0, 4) + '년'; }) },
series: [
{ data: successData },
{ data: timeoutData },
{ data: systemErrData },
{ data: bizErrData }
]
});
// 응답시간 차트 업데이트
respChart.setOption({
xAxis: { data: times.map(function(t) { return t.substring(0, 4) + '년'; }) },
series: [
{ data: p95RespData },
{ data: p50RespData },
{ data: avgRespData }
]
});
// 드릴다운을 위해 원본 데이터 저장
callChart.rawData = data;
respChart.rawData = data;
}
function fetchChartData() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
var postData = {
cmd: 'CHART',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val()
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$.ajax({
url: url,
type: 'POST',
data: postData,
success: function(response) {
updateCharts(response);
},
error: function(xhr, status, error) {
console.error('Chart data fetch error:', error);
}
});
}
function search() {
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
var postData = getSearchForJqgrid("cmd", "LIST");
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
$("#grid").setGridParam({ url: url, postData: postData, page: 1 }).trigger("reloadGrid");
fetchChartData();
}
function exportToExcel() {
console.log('[Excel Export] Starting...');
var searchStartDate = $("input[name=searchStartDateTime]").val().replace(/-/g, "");
var searchEndDate = $("input[name=searchEndDateTime]").val().replace(/-/g, "");
var postData = {
cmd: 'EXCEL_EXPORT',
searchApiName: $("input[name=searchApiName]").val(),
searchGwInstanceId: $("input[name=searchGwInstanceId]").val(),
searchBizDivCode: $("input[name=searchBizDivCode]").val(),
searchClientId: $("input[name=searchClientId]").val(),
searchInboundAdapter: $("input[name=searchInboundAdapter]").val(),
searchOutboundAdapter: $("input[name=searchOutboundAdapter]").val(),
serviceType: '${param.serviceType}'
};
if (searchStartDate) {
postData.searchStartDateTime = searchStartDate;
}
if (searchEndDate) {
postData.searchEndDateTime = searchEndDate;
}
console.log('[Excel Export] Request data:', postData);
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = 'blob';
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.onload = function() {
console.log('[Excel Export] Response received - Status:', xhr.status);
if (xhr.status === 200) {
var blob = xhr.response;
var contentType = xhr.getResponseHeader('Content-Type');
console.log('[Excel Export] Blob size:', blob.size, 'Content-Type:', contentType);
// JSON 에러 응답인지 확인
if (contentType && contentType.indexOf('application/json') !== -1) {
var reader = new FileReader();
reader.onload = function() {
try {
var errorObj = JSON.parse(reader.result);
alert(errorObj.message || 'Excel 파일 생성에 실패했습니다.');
} catch (e) {
alert('Excel 파일 생성에 실패했습니다.');
}
};
reader.readAsText(blob);
return;
}
// Blob 크기가 0이면 에러
if (blob.size === 0) {
alert('Excel 파일 생성에 실패했습니다. (빈 응답)');
return;
}
// 파일명 생성
var filename = 'API연간통계_' + searchStartDate + '.xlsx';
var disposition = xhr.getResponseHeader('Content-Disposition');
console.log('[Excel Export] Content-Disposition:', disposition);
if (disposition && disposition.indexOf('filename=') !== -1) {
var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
var matches = filenameRegex.exec(disposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
filename = decodeURIComponent(filename);
}
}
console.log('[Excel Export] Downloading as:', filename);
// 파일 다운로드
var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
console.log('[Excel Export] Download triggered');
} else if (xhr.status === 204) {
alert('조회된 데이터가 없습니다.');
} else {
// 에러 응답 처리
var reader = new FileReader();
reader.onload = function() {
var message = 'Excel 다운로드 중 오류가 발생했습니다.';
try {
var errorObj = JSON.parse(reader.result);
if (errorObj.message) message = errorObj.message;
} catch (e) {
console.error('[Excel Export] Error parsing error response:', e);
}
alert(message);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = function() {
console.error('[Excel Export] Network error');
alert('네트워크 오류가 발생했습니다.');
};
// Form data 생성
var formData = [];
for (var key in postData) {
if (postData.hasOwnProperty(key) && postData[key] != null && postData[key] !== '') {
formData.push(encodeURIComponent(key) + '=' + encodeURIComponent(postData[key]));
}
}
xhr.send(formData.join('&'));
}
function list() {
var gridPostData = getSearchForJqgrid("cmd", "LIST");
$('#grid').jqGrid({
datatype: "json",
mtype: 'POST',
postData: gridPostData,
colNames: [
'통계시간', 'API명', '인스턴스', '업무구분', '클라이언트ID',
'Inbound Adapter', 'Outbound Adapter',
'총건수', '성공', 'Timeout', '시스템오류', '업무오류',
'Seq900 Timeout', 'Seq900 시스템오류', 'Seq900 업무오류',
'평균응답(ms)', '최소응답(ms)', '최대응답(ms)', 'P50(ms)', 'P95(ms)'
],
colModel: [
{ name: 'statTime', align: 'center', width: '120', sortable: false },
{ name: 'apiName', align: 'left', width: '150', sortable: false },
{ name: 'gwInstanceId', align: 'center', width: '80', sortable: false },
{ name: 'bizDivCode', align: 'center', width: '80', sortable: false },
{ name: 'clientId', align: 'left', width: '100', sortable: false },
{ name: 'inboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'outboundAdapter', align: 'left', width: '120', sortable: false },
{ name: 'totalCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'successCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'timeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'systemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'bizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900TimeoutCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900SystemErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'seq900BizErrCnt', align: 'right', width: '70', formatter: numberFormatter, sortable: false },
{ name: 'avgRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'minRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'maxRespTime', align: 'right', width: '80', formatter: decimalFormatter, sortable: false },
{ name: 'p50RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false },
{ name: 'p95RespTime', align: 'right', width: '70', formatter: decimalFormatter, sortable: false }
],
jsonReader: { repeatitems: false },
pager: $('#pager'),
page: '${param.page}',
rowNum: '${rmsDefaultRowNum}',
autoheight: true,
height: $("#container").height(),
autowidth: true,
viewrecords: true,
rowList: eval('[${rmsDefaultRowList}]'),
loadComplete: function(d) {
var colModel = $(this).getGridParam("colModel");
for (var i = 0; i < colModel.length; i++) {
$(this).setColProp(colModel[i].name, { sortable: false });
}
}
});
}
$(document).ready(function() {
// 연도 입력 마스크
$("input[name=searchStartDateTime]").inputmask("9999", { 'autoUnmask': true });
$("input[name=searchEndDateTime]").inputmask("9999", { 'autoUnmask': true });
// 초기값 설정: 5년 전 ~ 현재 연도
if (!$("input[name=searchStartDateTime]").val()) {
var currentYear = new Date().getFullYear();
var startYear = currentYear - 5;
$("input[name=searchStartDateTime]").val(startYear);
$("input[name=searchEndDateTime]").val(currentYear);
}
initCharts();
list();
search();
fetchChartData();
resizeJqGridWidth('grid', 'content_middle', '1200');
$("#btn_search").click(function() {
search();
});
$("#btn_excel_export").click(function() {
exportToExcel();
});
$("input[name^=search]").keydown(function(key) {
if (key.keyCode == 13) {
$("#btn_search").click();
}
});
// 윈도우 리사이즈 시 차트 리사이즈
$(window).resize(function() {
if (totalDonutChart) totalDonutChart.resize();
if (seq900DonutChart) seq900DonutChart.resize();
if (callChart) callChart.resize();
if (respChart) respChart.resize();
});
buttonControl();
});
</script>
</head>
<body>
<div class="right_box">
<div class="content_top">
<ul class="path">
<li><a href="#">${rmsMenuPath}</a></li>
</ul>
</div>
<div class="content_middle" id="content_middle">
<div class="search_wrap">
<button type="button" class="cssbtn" id="btn_search" level="R">
<i class="material-icons">search</i> <%= localeMessage.getString("button.search") %>
</button>
<button type="button" class="cssbtn" id="btn_excel_export" level="R">
<i class="material-icons">file_download</i> Excel 다운로드
</button>
</div>
<div class="title" id="title">API 연간 통계</div>
<table class="search_condition" cellspacing="0">
<tbody>
<tr>
<th style="width:100px;">조회기간</th>
<td colspan="3">
<input type="text" name="searchStartDateTime" value="${param.searchStartDateTime}" style="width:100px;" placeholder="YYYY">
~
<input type="text" name="searchEndDateTime" value="${param.searchEndDateTime}" style="width:100px;" placeholder="YYYY">
</td>
</tr>
<tr>
<th style="width:100px;">API명</th>
<td>
<input type="text" name="searchApiName" value="${param.searchApiName}">
</td>
<th style="width:100px;">인스턴스</th>
<td>
<input type="text" name="searchGwInstanceId" value="${param.searchGwInstanceId}">
</td>
</tr>
<tr>
<th style="width:100px;">업무구분</th>
<td>
<input type="text" name="searchBizDivCode" value="${param.searchBizDivCode}">
</td>
<th style="width:100px;">클라이언트ID</th>
<td>
<input type="text" name="searchClientId" value="${param.searchClientId}">
</td>
</tr>
<tr>
<th style="width:100px;">Inbound Adapter</th>
<td>
<input type="text" name="searchInboundAdapter" value="${param.searchInboundAdapter}">
</td>
<th style="width:100px;">Outbound Adapter</th>
<td>
<input type="text" name="searchOutboundAdapter" value="${param.searchOutboundAdapter}">
</td>
</tr>
</tbody>
</table>
<!-- 도넛 차트 (상단 50%씩) -->
<div class="chart-container">
<div id="totalDonutChart" class="chart"></div>
<div id="seq900DonutChart" class="chart"></div>
</div>
<!-- 호출량 및 응답시간 차트 (하단 50%씩) -->
<div class="chart-container">
<div id="callChart" class="chart"></div>
<div id="respChart" class="chart"></div>
</div>
<table id="grid"></table>
<div id="pager"></div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,93 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* API Gateway 일별 처리 통계
*/
@Getter
@Setter
@Entity
@Table(name = "API_STATS_DAY")
@IdClass(ApiStatsDayId.class)
public class ApiStatsDay implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "STAT_TIME", nullable = false)
private LocalDate statTime;
@Id
@Column(name = "API_NAME", nullable = false, length = 100)
private String apiName;
@Id
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
private String gwInstanceId;
@Id
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
private String bizDivCode = "NONE";
@Id
@Column(name = "CLIENT_ID", nullable = false, length = 100)
private String clientId = "NONE";
@Id
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
private String inboundAdapter = "NONE";
@Id
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
private String outboundAdapter = "NONE";
@Column(name = "TOTAL_CNT", nullable = false)
private Long totalCnt = 0L;
@Column(name = "SUCCESS_CNT", nullable = false)
private Long successCnt = 0L;
@Column(name = "TIMEOUT_CNT", nullable = false)
private Long timeoutCnt = 0L;
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
private Long systemErrCnt = 0L;
@Column(name = "BIZ_ERR_CNT", nullable = false)
private Long bizErrCnt = 0L;
@Column(name = "SEQ900_TIMEOUT_CNT", nullable = false)
private Long seq900TimeoutCnt = 0L;
@Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false)
private Long seq900SystemErrCnt = 0L;
@Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false)
private Long seq900BizErrCnt = 0L;
@Column(name = "AVG_RESP_TIME", precision = 10, scale = 3)
private BigDecimal avgRespTime = BigDecimal.ZERO;
@Column(name = "MIN_RESP_TIME", precision = 10, scale = 3)
private BigDecimal minRespTime = BigDecimal.ZERO;
@Column(name = "MAX_RESP_TIME", precision = 10, scale = 3)
private BigDecimal maxRespTime = BigDecimal.ZERO;
@Column(name = "P50_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p50RespTime = BigDecimal.ZERO;
@Column(name = "P95_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p95RespTime = BigDecimal.ZERO;
}
@@ -0,0 +1,26 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API 통계 일별 복합키
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatsDayId implements Serializable {
private static final long serialVersionUID = 1L;
private LocalDate statTime;
private String apiName;
private String gwInstanceId;
private String bizDivCode;
private String clientId;
private String inboundAdapter;
private String outboundAdapter;
}
@@ -0,0 +1,10 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import com.eactive.eai.data.jpa.BaseRepository;
/**
* API 통계 일별 Repository
*/
interface ApiStatsDayRepository extends BaseRepository<ApiStatsDay, ApiStatsDayId> {
}
@@ -0,0 +1,152 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.data.jpa.AbstractDataService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Projections;
@Service
@Transactional
public class ApiStatsDayService
extends AbstractDataService<ApiStatsDay, ApiStatsDayId, ApiStatsDayRepository> {
private static final int MAX_DAYS = 31;
public Page<ApiStatsDay> selectList(ApiStatsSearch search, Pageable pageable) {
QApiStatsDay q = QApiStatsDay.apiStatsDay;
BooleanBuilder builder = buildSearchConditions(q, search);
return repository.findAll(builder, pageable);
}
public List<ApiStatsDay> selectListForExcel(ApiStatsSearch search) {
QApiStatsDay q = QApiStatsDay.apiStatsDay;
BooleanBuilder builder = buildSearchConditions(q, search);
return getJPAQueryFactory()
.selectFrom(q)
.where(builder)
.orderBy(q.statTime.desc())
.fetch();
}
public List<ApiStatsChartUI> selectChartData(ApiStatsSearch search) {
QApiStatsDay q = QApiStatsDay.apiStatsDay;
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartDate() != null) {
builder.and(q.statTime.goe(search.getParsedStartDate()));
builder.and(q.statTime.loe(search.getParsedEndDate()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
return getJPAQueryFactory()
.select(Projections.constructor(ApiStatsChartUI.class,
q.statTime,
q.totalCnt.sum(),
q.successCnt.sum(),
q.timeoutCnt.sum(),
q.systemErrCnt.sum(),
q.bizErrCnt.sum(),
q.seq900TimeoutCnt.sum(),
q.seq900SystemErrCnt.sum(),
q.seq900BizErrCnt.sum(),
q.avgRespTime.avg(),
q.p50RespTime.avg(),
q.p95RespTime.avg()))
.from(q)
.where(builder)
.groupBy(q.statTime)
.orderBy(q.statTime.asc())
.fetch();
}
private BooleanBuilder buildSearchConditions(QApiStatsDay q, ApiStatsSearch search) {
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartDate() != null) {
builder.and(q.statTime.goe(search.getParsedStartDate()));
builder.and(q.statTime.loe(search.getParsedEndDate()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) {
builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter()));
}
if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) {
builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter()));
}
return builder;
}
/**
* 조회 기간 계산 (최대 31일 제한)
*/
private void calculateDateRange(ApiStatsSearch search) {
if (StringUtils.isBlank(search.getSearchStartDateTime())) {
return;
}
LocalDate startDate = LocalDate.parse(search.getSearchStartDateTime(),
DateTimeFormatter.ofPattern("yyyyMMdd"));
LocalDate endDate;
if (StringUtils.isNotBlank(search.getSearchEndDateTime())) {
endDate = LocalDate.parse(search.getSearchEndDateTime(),
DateTimeFormatter.ofPattern("yyyyMMdd"));
// 31일 초과 시 시작날짜 기준 31일 후로 강제 설정
if (java.time.Period.between(startDate, endDate).getDays() > MAX_DAYS) {
endDate = startDate.plusDays(MAX_DAYS);
}
} else {
endDate = startDate.plusDays(MAX_DAYS);
}
search.setParsedStartDate(startDate);
search.setParsedEndDate(endDate);
}
}
@@ -0,0 +1,92 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import java.math.BigDecimal;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* API Gateway 월별 처리 통계
*/
@Getter
@Setter
@Entity
@Table(name = "API_STATS_MONTH")
@IdClass(ApiStatsMonthId.class)
public class ApiStatsMonth implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "STAT_TIME", nullable = false, length = 6)
private String statTime; // YYYYMM format
@Id
@Column(name = "API_NAME", nullable = false, length = 100)
private String apiName;
@Id
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
private String gwInstanceId;
@Id
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
private String bizDivCode = "NONE";
@Id
@Column(name = "CLIENT_ID", nullable = false, length = 100)
private String clientId = "NONE";
@Id
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
private String inboundAdapter = "NONE";
@Id
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
private String outboundAdapter = "NONE";
@Column(name = "TOTAL_CNT", nullable = false)
private Long totalCnt = 0L;
@Column(name = "SUCCESS_CNT", nullable = false)
private Long successCnt = 0L;
@Column(name = "TIMEOUT_CNT", nullable = false)
private Long timeoutCnt = 0L;
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
private Long systemErrCnt = 0L;
@Column(name = "BIZ_ERR_CNT", nullable = false)
private Long bizErrCnt = 0L;
@Column(name = "SEQ900_TIMEOUT_CNT", nullable = false)
private Long seq900TimeoutCnt = 0L;
@Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false)
private Long seq900SystemErrCnt = 0L;
@Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false)
private Long seq900BizErrCnt = 0L;
@Column(name = "AVG_RESP_TIME", precision = 10, scale = 3)
private BigDecimal avgRespTime = BigDecimal.ZERO;
@Column(name = "MIN_RESP_TIME", precision = 10, scale = 3)
private BigDecimal minRespTime = BigDecimal.ZERO;
@Column(name = "MAX_RESP_TIME", precision = 10, scale = 3)
private BigDecimal maxRespTime = BigDecimal.ZERO;
@Column(name = "P50_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p50RespTime = BigDecimal.ZERO;
@Column(name = "P95_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p95RespTime = BigDecimal.ZERO;
}
@@ -0,0 +1,25 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API 통계 월별 복합키
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatsMonthId implements Serializable {
private static final long serialVersionUID = 1L;
private String statTime; // YYYYMM format
private String apiName;
private String gwInstanceId;
private String bizDivCode;
private String clientId;
private String inboundAdapter;
private String outboundAdapter;
}
@@ -0,0 +1,10 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import com.eactive.eai.data.jpa.BaseRepository;
/**
* API 통계 월별 Repository
*/
interface ApiStatsMonthRepository extends BaseRepository<ApiStatsMonth, ApiStatsMonthId> {
}
@@ -0,0 +1,153 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.data.jpa.AbstractDataService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Projections;
@Service
@Transactional
public class ApiStatsMonthService
extends AbstractDataService<ApiStatsMonth, ApiStatsMonthId, ApiStatsMonthRepository> {
private static final int MAX_MONTHS = 24;
public Page<ApiStatsMonth> selectList(ApiStatsSearch search, Pageable pageable) {
QApiStatsMonth q = QApiStatsMonth.apiStatsMonth;
BooleanBuilder builder = buildSearchConditions(q, search);
return repository.findAll(builder, pageable);
}
public List<ApiStatsMonth> selectListForExcel(ApiStatsSearch search) {
QApiStatsMonth q = QApiStatsMonth.apiStatsMonth;
BooleanBuilder builder = buildSearchConditions(q, search);
return getJPAQueryFactory()
.selectFrom(q)
.where(builder)
.orderBy(q.statTime.desc())
.fetch();
}
public List<ApiStatsChartUI> selectChartData(ApiStatsSearch search) {
QApiStatsMonth q = QApiStatsMonth.apiStatsMonth;
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartMonth() != null) {
builder.and(q.statTime.goe(search.getParsedStartMonth()));
builder.and(q.statTime.loe(search.getParsedEndMonth()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
return getJPAQueryFactory()
.select(Projections.constructor(ApiStatsChartUI.class,
q.statTime,
q.totalCnt.sum(),
q.successCnt.sum(),
q.timeoutCnt.sum(),
q.systemErrCnt.sum(),
q.bizErrCnt.sum(),
q.seq900TimeoutCnt.sum(),
q.seq900SystemErrCnt.sum(),
q.seq900BizErrCnt.sum(),
q.avgRespTime.avg(),
q.p50RespTime.avg(),
q.p95RespTime.avg()))
.from(q)
.where(builder)
.groupBy(q.statTime)
.orderBy(q.statTime.asc())
.fetch();
}
private BooleanBuilder buildSearchConditions(QApiStatsMonth q, ApiStatsSearch search) {
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartMonth() != null) {
builder.and(q.statTime.goe(search.getParsedStartMonth()));
builder.and(q.statTime.loe(search.getParsedEndMonth()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) {
builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter()));
}
if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) {
builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter()));
}
return builder;
}
/**
* 조회 기간 계산 (최대 24개월 제한)
*/
private void calculateDateRange(ApiStatsSearch search) {
if (StringUtils.isBlank(search.getSearchStartDateTime())) {
return;
}
YearMonth startMonth = YearMonth.parse(search.getSearchStartDateTime(),
DateTimeFormatter.ofPattern("yyyyMM"));
YearMonth endMonth;
if (StringUtils.isNotBlank(search.getSearchEndDateTime())) {
endMonth = YearMonth.parse(search.getSearchEndDateTime(),
DateTimeFormatter.ofPattern("yyyyMM"));
// 12개월 초과 시 시작월 기준 12개월 후로 강제 설정
long monthsBetween = java.time.temporal.ChronoUnit.MONTHS.between(startMonth, endMonth);
if (monthsBetween > MAX_MONTHS) {
endMonth = startMonth.plusMonths(MAX_MONTHS);
}
} else {
endMonth = startMonth.plusMonths(MAX_MONTHS);
}
search.setParsedStartMonth(startMonth.format(DateTimeFormatter.ofPattern("yyyyMM")));
search.setParsedEndMonth(endMonth.format(DateTimeFormatter.ofPattern("yyyyMM")));
}
}
@@ -0,0 +1,92 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import java.math.BigDecimal;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.IdClass;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* API Gateway 연간 처리 통계
*/
@Getter
@Setter
@Entity
@Table(name = "API_STATS_YEAR")
@IdClass(ApiStatsYearId.class)
public class ApiStatsYear implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "STAT_TIME", nullable = false, length = 4)
private String statTime; // YYYY format
@Id
@Column(name = "API_NAME", nullable = false, length = 100)
private String apiName;
@Id
@Column(name = "GW_INSTANCE_ID", nullable = false, length = 50)
private String gwInstanceId;
@Id
@Column(name = "BIZ_DIV_CODE", nullable = false, length = 50)
private String bizDivCode = "NONE";
@Id
@Column(name = "CLIENT_ID", nullable = false, length = 100)
private String clientId = "NONE";
@Id
@Column(name = "INBOUND_ADAPTER", nullable = false, length = 100)
private String inboundAdapter = "NONE";
@Id
@Column(name = "OUTBOUND_ADAPTER", nullable = false, length = 100)
private String outboundAdapter = "NONE";
@Column(name = "TOTAL_CNT", nullable = false)
private Long totalCnt = 0L;
@Column(name = "SUCCESS_CNT", nullable = false)
private Long successCnt = 0L;
@Column(name = "TIMEOUT_CNT", nullable = false)
private Long timeoutCnt = 0L;
@Column(name = "SYSTEM_ERR_CNT", nullable = false)
private Long systemErrCnt = 0L;
@Column(name = "BIZ_ERR_CNT", nullable = false)
private Long bizErrCnt = 0L;
@Column(name = "SEQ900_TIMEOUT_CNT", nullable = false)
private Long seq900TimeoutCnt = 0L;
@Column(name = "SEQ900_SYSTEM_ERR_CNT", nullable = false)
private Long seq900SystemErrCnt = 0L;
@Column(name = "SEQ900_BIZ_ERR_CNT", nullable = false)
private Long seq900BizErrCnt = 0L;
@Column(name = "AVG_RESP_TIME", precision = 10, scale = 3)
private BigDecimal avgRespTime = BigDecimal.ZERO;
@Column(name = "MIN_RESP_TIME", precision = 10, scale = 3)
private BigDecimal minRespTime = BigDecimal.ZERO;
@Column(name = "MAX_RESP_TIME", precision = 10, scale = 3)
private BigDecimal maxRespTime = BigDecimal.ZERO;
@Column(name = "P50_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p50RespTime = BigDecimal.ZERO;
@Column(name = "P95_RESP_TIME", precision = 10, scale = 3)
private BigDecimal p95RespTime = BigDecimal.ZERO;
}
@@ -0,0 +1,25 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API 통계 연간 복합키
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiStatsYearId implements Serializable {
private static final long serialVersionUID = 1L;
private String statTime; // YYYY format
private String apiName;
private String gwInstanceId;
private String bizDivCode;
private String clientId;
private String inboundAdapter;
private String outboundAdapter;
}
@@ -0,0 +1,10 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import com.eactive.eai.data.jpa.BaseRepository;
/**
* API 통계 연간 Repository
*/
interface ApiStatsYearRepository extends BaseRepository<ApiStatsYear, ApiStatsYearId> {
}
@@ -0,0 +1,143 @@
package com.eactive.eai.rms.data.entity.onl.kjb.statistics;
import java.time.Year;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.data.jpa.AbstractDataService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Projections;
@Service
@Transactional
public class ApiStatsYearService
extends AbstractDataService<ApiStatsYear, ApiStatsYearId, ApiStatsYearRepository> {
public Page<ApiStatsYear> selectList(ApiStatsSearch search, Pageable pageable) {
QApiStatsYear q = QApiStatsYear.apiStatsYear;
BooleanBuilder builder = buildSearchConditions(q, search);
return repository.findAll(builder, pageable);
}
public List<ApiStatsYear> selectListForExcel(ApiStatsSearch search) {
QApiStatsYear q = QApiStatsYear.apiStatsYear;
BooleanBuilder builder = buildSearchConditions(q, search);
return getJPAQueryFactory()
.selectFrom(q)
.where(builder)
.orderBy(q.statTime.desc())
.fetch();
}
public List<ApiStatsChartUI> selectChartData(ApiStatsSearch search) {
QApiStatsYear q = QApiStatsYear.apiStatsYear;
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartYear() != null) {
builder.and(q.statTime.goe(search.getParsedStartYear()));
builder.and(q.statTime.loe(search.getParsedEndYear()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
return getJPAQueryFactory()
.select(Projections.constructor(ApiStatsChartUI.class,
q.statTime,
q.totalCnt.sum(),
q.successCnt.sum(),
q.timeoutCnt.sum(),
q.systemErrCnt.sum(),
q.bizErrCnt.sum(),
q.seq900TimeoutCnt.sum(),
q.seq900SystemErrCnt.sum(),
q.seq900BizErrCnt.sum(),
q.avgRespTime.avg(),
q.p50RespTime.avg(),
q.p95RespTime.avg()))
.from(q)
.where(builder)
.groupBy(q.statTime)
.orderBy(q.statTime.asc())
.fetch();
}
private BooleanBuilder buildSearchConditions(QApiStatsYear q, ApiStatsSearch search) {
BooleanBuilder builder = new BooleanBuilder();
calculateDateRange(search);
if (search.getParsedStartYear() != null) {
builder.and(q.statTime.goe(search.getParsedStartYear()));
builder.and(q.statTime.loe(search.getParsedEndYear()));
}
if (StringUtils.isNotBlank(search.getSearchApiName())) {
builder.and(q.apiName.containsIgnoreCase(search.getSearchApiName()));
}
if (StringUtils.isNotBlank(search.getSearchGwInstanceId())) {
builder.and(q.gwInstanceId.containsIgnoreCase(search.getSearchGwInstanceId()));
}
if (StringUtils.isNotBlank(search.getSearchBizDivCode())) {
builder.and(q.bizDivCode.containsIgnoreCase(search.getSearchBizDivCode()));
}
if (StringUtils.isNotBlank(search.getSearchClientId())) {
builder.and(q.clientId.containsIgnoreCase(search.getSearchClientId()));
}
if (StringUtils.isNotBlank(search.getSearchInboundAdapter())) {
builder.and(q.inboundAdapter.containsIgnoreCase(search.getSearchInboundAdapter()));
}
if (StringUtils.isNotBlank(search.getSearchOutboundAdapter())) {
builder.and(q.outboundAdapter.containsIgnoreCase(search.getSearchOutboundAdapter()));
}
return builder;
}
/**
* 조회 기간 계산
*/
private void calculateDateRange(ApiStatsSearch search) {
if (StringUtils.isBlank(search.getSearchStartDateTime())) {
return;
}
Year startYear = Year.parse(search.getSearchStartDateTime());
Year endYear;
if (StringUtils.isNotBlank(search.getSearchEndDateTime())) {
endYear = Year.parse(search.getSearchEndDateTime());
} else {
endYear = Year.now();
}
search.setParsedStartYear(String.valueOf(startYear.getValue()));
search.setParsedEndYear(String.valueOf(endYear.getValue()));
}
}
@@ -0,0 +1,185 @@
package com.eactive.ext.kjb.statistics;
import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.eactive.ext.kjb.statistics.job.DailyToMonthlyAggregationJob;
import com.eactive.ext.kjb.statistics.job.HourlyToDailyAggregationJob;
import com.eactive.ext.kjb.statistics.job.MonthlyToYearlyAggregationJob;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* API 통계 집계 수동 실행 Controller
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class ApiStatsAggregationController {
private final HourlyToDailyAggregationJob hourlyToDailyAggregationJob;
private final DailyToMonthlyAggregationJob dailyToMonthlyAggregationJob;
private final MonthlyToYearlyAggregationJob monthlyToYearlyAggregationJob;
@GetMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.view")
public String view() {
return "/onl/kjb/statistics/apiStatsAggregationMan";
}
/**
* 시간별→일별 통계 집계 수동 실행
* @param targetDate 집계 대상 날짜 (yyyyMMdd 형식, 예: 20250120)
* @return 처리 결과
*/
@PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_DAY")
public ResponseEntity<Map<String, Object>> executeHourlyToDaily(
@RequestParam(required = false) String targetDate) {
Map<String, Object> result = new HashMap<>();
try {
// 날짜 입력 검증
if (targetDate == null || targetDate.trim().isEmpty()) {
log.error("targetDate 미입력");
result.put("success", false);
result.put("message", "대상 날짜를 입력하세요. (예: 20250120)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
LocalDate date;
try {
date = LocalDate.parse(targetDate, DateTimeFormatter.ofPattern("yyyyMMdd"));
} catch (DateTimeParseException e) {
log.error("잘못된 날짜 형식: {}", targetDate);
result.put("success", false);
result.put("message", "잘못된 날짜 형식입니다. yyyyMMdd 형식으로 입력하세요. (예: 20250120)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
log.info("시간별→일별 통계 집계 수동 실행 시작. targetDate: {}", date);
int count = hourlyToDailyAggregationJob.executeManual(date);
result.put("success", true);
result.put("message", "시간별→일별 통계 집계가 완료되었습니다.");
result.put("targetDate", date.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
result.put("processedCount", count);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("시간별→일별 통계 집계 실행 중 오류 발생", e);
result.put("success", false);
result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 일별→월별 통계 집계 수동 실행
* @param targetMonth 집계 대상 월 (yyyyMM 형식, 예: 202501)
* @return 처리 결과
*/
@PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_MONTH")
public ResponseEntity<Map<String, Object>> executeDailyToMonthly(
@RequestParam(required = false) String targetMonth) {
Map<String, Object> result = new HashMap<>();
try {
// 월 입력 검증
if (targetMonth == null || targetMonth.trim().isEmpty()) {
log.error("targetMonth 미입력");
result.put("success", false);
result.put("message", "대상 월을 입력하세요. (예: 202501)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
YearMonth month;
try {
month = YearMonth.parse(targetMonth, DateTimeFormatter.ofPattern("yyyyMM"));
} catch (DateTimeParseException e) {
log.error("잘못된 월 형식: {}", targetMonth);
result.put("success", false);
result.put("message", "잘못된 월 형식입니다. yyyyMM 형식으로 입력하세요. (예: 202501)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
log.info("일별→월별 통계 집계 수동 실행 시작. targetMonth: {}", month);
int count = dailyToMonthlyAggregationJob.executeManual(month);
result.put("success", true);
result.put("message", "일별→월별 통계 집계가 완료되었습니다.");
result.put("targetMonth", month.format(DateTimeFormatter.ofPattern("yyyyMM")));
result.put("processedCount", count);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("일별→월별 통계 집계 실행 중 오류 발생", e);
result.put("success", false);
result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
/**
* 월별→연별 통계 집계 수동 실행
* @param targetYear 집계 대상 연도 (yyyy 형식, 예: 2024)
* @return 처리 결과
*/
@PostMapping(value = "/onl/kjb/statistics/apiStatsAggregationMan.json", params = "cmd=AGGREGATION_YEAR")
public ResponseEntity<Map<String, Object>> executeMonthlyToYearly(
@RequestParam(required = false) String targetYear) {
Map<String, Object> result = new HashMap<>();
try {
// 연도 입력 검증
if (targetYear == null || targetYear.trim().isEmpty()) {
log.error("targetYear 미입력");
result.put("success", false);
result.put("message", "대상 연도를 입력하세요. (예: 2024)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
Year year;
try {
year = Year.parse(targetYear);
} catch (DateTimeParseException e) {
log.error("잘못된 연도 형식: {}", targetYear);
result.put("success", false);
result.put("message", "잘못된 연도 형식입니다. yyyy 형식으로 입력하세요. (예: 2024)");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
}
log.info("월별→연별 통계 집계 수동 실행 시작. targetYear: {}", year);
int count = monthlyToYearlyAggregationJob.executeManual(year);
result.put("success", true);
result.put("message", "월별→연별 통계 집계가 완료되었습니다.");
result.put("targetYear", year.toString());
result.put("processedCount", count);
return ResponseEntity.ok(result);
} catch (Exception e) {
log.error("월별→연별 통계 집계 실행 중 오류 발생", e);
result.put("success", false);
result.put("message", "집계 실행 중 오류가 발생했습니다: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(result);
}
}
}
@@ -0,0 +1,115 @@
package com.eactive.ext.kjb.statistics;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService;
import com.eactive.ext.kjb.statistics.mapping.ApiStatsUIMapper;
import com.eactive.ext.kjb.statistics.service.ApiStatsExcelExportService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.eactive.ext.kjb.statistics.ui.ApiStatsUI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ApiStatsDayController {
private final ApiStatsDayService service;
private final ApiStatsUIMapper mapper;
private final ApiStatsExcelExportService excelExportService;
@GetMapping(value = "/onl/kjb/statistics/apiStatsDayMan.view")
public String view() {
return "/onl/kjb/statistics/apiStatsDayMan";
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsDayMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<ApiStatsUI>> selectList(ApiStatsSearch search, Pageable pageable) {
Pageable sortedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(Sort.Order.desc("statTime")));
Page<ApiStatsDay> page = service.selectList(search, sortedPageable);
Page<ApiStatsUI> uiPage = page.map(mapper::toVo);
return ResponseEntity.ok(new GridResponse<>(uiPage));
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsDayMan.json", params = "cmd=CHART")
public ResponseEntity<List<ApiStatsChartUI>> selectChartData(ApiStatsSearch search) {
List<ApiStatsChartUI> chartData = service.selectChartData(search);
return ResponseEntity.ok(chartData);
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsDayMan.json", params = "cmd=EXCEL_EXPORT")
public void exportToExcel(ApiStatsSearch search, HttpServletResponse response) throws IOException {
log.info("Excel export started - search: {}", search);
try {
List<ApiStatsDay> dataList = service.selectListForExcel(search);
log.info("Data retrieved - count: {}", dataList.size());
if (dataList.isEmpty()) {
log.warn("No data found for export");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"조회된 데이터가 없습니다.\"}");
response.getWriter().flush();
return;
}
List<ApiStatsUI> uiList = dataList.stream()
.map(mapper::toVo)
.collect(Collectors.toList());
log.info("Data converted to UI list - count: {}", uiList.size());
String fileName = generateFileName(search);
log.info("Generating Excel file: {}", fileName);
excelExportService.exportApiStats(uiList, fileName, response);
log.info("Excel export completed successfully");
} catch (Exception e) {
log.error("Excel export failed - error: {}", e.getMessage(), e);
if (!response.isCommitted()) {
response.reset();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"Excel 파일 생성 중 오류가 발생했습니다: " + e.getMessage() + "\"}");
response.getWriter().flush();
} else {
log.error("Cannot send error response - response already committed");
}
}
}
private String generateFileName(ApiStatsSearch search) {
String timeRange = StringUtils.isNotBlank(search.getSearchStartDateTime())
? search.getSearchStartDateTime()
: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
return "API일별통계_" + timeRange + ".xlsx";
}
}
@@ -0,0 +1,115 @@
package com.eactive.ext.kjb.statistics;
import java.io.IOException;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonth;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService;
import com.eactive.ext.kjb.statistics.mapping.ApiStatsUIMapper;
import com.eactive.ext.kjb.statistics.service.ApiStatsExcelExportService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.eactive.ext.kjb.statistics.ui.ApiStatsUI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ApiStatsMonthController {
private final ApiStatsMonthService service;
private final ApiStatsUIMapper mapper;
private final ApiStatsExcelExportService excelExportService;
@GetMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.view")
public String view() {
return "/onl/kjb/statistics/apiStatsMonthMan";
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<ApiStatsUI>> selectList(ApiStatsSearch search, Pageable pageable) {
Pageable sortedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(Sort.Order.desc("statTime")));
Page<ApiStatsMonth> page = service.selectList(search, sortedPageable);
Page<ApiStatsUI> uiPage = page.map(mapper::toVo);
return ResponseEntity.ok(new GridResponse<>(uiPage));
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.json", params = "cmd=CHART")
public ResponseEntity<List<ApiStatsChartUI>> selectChartData(ApiStatsSearch search) {
List<ApiStatsChartUI> chartData = service.selectChartData(search);
return ResponseEntity.ok(chartData);
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsMonthMan.json", params = "cmd=EXCEL_EXPORT")
public void exportToExcel(ApiStatsSearch search, HttpServletResponse response) throws IOException {
log.info("Excel export started - search: {}", search);
try {
List<ApiStatsMonth> dataList = service.selectListForExcel(search);
log.info("Data retrieved - count: {}", dataList.size());
if (dataList.isEmpty()) {
log.warn("No data found for export");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"조회된 데이터가 없습니다.\"}");
response.getWriter().flush();
return;
}
List<ApiStatsUI> uiList = dataList.stream()
.map(mapper::toVo)
.collect(Collectors.toList());
log.info("Data converted to UI list - count: {}", uiList.size());
String fileName = generateFileName(search);
log.info("Generating Excel file: {}", fileName);
excelExportService.exportApiStats(uiList, fileName, response);
log.info("Excel export completed successfully");
} catch (Exception e) {
log.error("Excel export failed - error: {}", e.getMessage(), e);
if (!response.isCommitted()) {
response.reset();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"Excel 파일 생성 중 오류가 발생했습니다: " + e.getMessage() + "\"}");
response.getWriter().flush();
} else {
log.error("Cannot send error response - response already committed");
}
}
}
private String generateFileName(ApiStatsSearch search) {
String timeRange = StringUtils.isNotBlank(search.getSearchStartDateTime())
? search.getSearchStartDateTime()
: YearMonth.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
return "API월별통계_" + timeRange + ".xlsx";
}
}
@@ -0,0 +1,114 @@
package com.eactive.ext.kjb.statistics;
import java.io.IOException;
import java.time.Year;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.eactive.eai.rms.common.vo.GridResponse;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYear;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYearService;
import com.eactive.ext.kjb.statistics.mapping.ApiStatsUIMapper;
import com.eactive.ext.kjb.statistics.service.ApiStatsExcelExportService;
import com.eactive.ext.kjb.statistics.ui.ApiStatsChartUI;
import com.eactive.ext.kjb.statistics.ui.ApiStatsSearch;
import com.eactive.ext.kjb.statistics.ui.ApiStatsUI;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Controller
@RequiredArgsConstructor
public class ApiStatsYearController {
private final ApiStatsYearService service;
private final ApiStatsUIMapper mapper;
private final ApiStatsExcelExportService excelExportService;
@GetMapping(value = "/onl/kjb/statistics/apiStatsYearMan.view")
public String view() {
return "/onl/kjb/statistics/apiStatsYearMan";
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsYearMan.json", params = "cmd=LIST")
public ResponseEntity<GridResponse<ApiStatsUI>> selectList(ApiStatsSearch search, Pageable pageable) {
Pageable sortedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
Sort.by(Sort.Order.desc("statTime")));
Page<ApiStatsYear> page = service.selectList(search, sortedPageable);
Page<ApiStatsUI> uiPage = page.map(mapper::toVo);
return ResponseEntity.ok(new GridResponse<>(uiPage));
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsYearMan.json", params = "cmd=CHART")
public ResponseEntity<List<ApiStatsChartUI>> selectChartData(ApiStatsSearch search) {
List<ApiStatsChartUI> chartData = service.selectChartData(search);
return ResponseEntity.ok(chartData);
}
@PostMapping(value = "/onl/kjb/statistics/apiStatsYearMan.json", params = "cmd=EXCEL_EXPORT")
public void exportToExcel(ApiStatsSearch search, HttpServletResponse response) throws IOException {
log.info("Excel export started - search: {}", search);
try {
List<ApiStatsYear> dataList = service.selectListForExcel(search);
log.info("Data retrieved - count: {}", dataList.size());
if (dataList.isEmpty()) {
log.warn("No data found for export");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"조회된 데이터가 없습니다.\"}");
response.getWriter().flush();
return;
}
List<ApiStatsUI> uiList = dataList.stream()
.map(mapper::toVo)
.collect(Collectors.toList());
log.info("Data converted to UI list - count: {}", uiList.size());
String fileName = generateFileName(search);
log.info("Generating Excel file: {}", fileName);
excelExportService.exportApiStats(uiList, fileName, response);
log.info("Excel export completed successfully");
} catch (Exception e) {
log.error("Excel export failed - error: {}", e.getMessage(), e);
if (!response.isCommitted()) {
response.reset();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write("{\"message\":\"Excel 파일 생성 중 오류가 발생했습니다: " + e.getMessage() + "\"}");
response.getWriter().flush();
} else {
log.error("Cannot send error response - response already committed");
}
}
}
private String generateFileName(ApiStatsSearch search) {
String timeRange = StringUtils.isNotBlank(search.getSearchStartDateTime())
? search.getSearchStartDateTime()
: String.valueOf(Year.now().getValue());
return "API연간통계_" + timeRange + ".xlsx";
}
}
@@ -1,102 +0,0 @@
# API Gateway 통계 화면
## 개요
API Gateway의 분단위/시간별 처리 통계를 조회하는 관리 화면입니다.
## 파일 구조
```
com.eactive.ext.kjb.statistics/
├── ApiStatsMinuteController.java # 분단위 통계 Controller
├── ApiStatsMinuteService.java # 분단위 통계 Service
├── ApiStatsHourController.java # 시간별 통계 Controller
├── ApiStatsHourService.java # 시간별 통계 Service
├── entity/
│ ├── ApiStatsMinute.java # 분단위 통계 Entity
│ ├── ApiStatsMinuteId.java # 분단위 통계 복합키
│ ├── ApiStatsHour.java # 시간별 통계 Entity
│ └── ApiStatsHourId.java # 시간별 통계 복합키
├── repository/
│ ├── ApiStatsMinuteRepository.java
│ └── ApiStatsHourRepository.java
├── ui/
│ ├── ApiStatsUI.java # 통계 UI DTO
│ └── ApiStatsSearch.java # 검색조건 DTO
├── mapping/
│ └── ApiStatsUIMapper.java # Entity ↔ UI 매핑
└── README.md
JSP 화면:
WebContent/jsp/kjb/statistics/
├── apiStatsMinuteMan.jsp # 분단위 통계 화면
└── apiStatsHourMan.jsp # 시간별 통계 화면
```
## URL
| 화면 | URL |
|------|-----|
| 분단위 통계 | `/kjb/statistics/apiStatsMinuteMan.view` |
| 시간별 통계 | `/kjb/statistics/apiStatsHourMan.view` |
## 테이블
| 테이블명 | 설명 |
|----------|------|
| API_STATS_MINUTE | 분단위 통계 |
| API_STATS_HOUR | 시간별 통계 |
## 복합키 구성
- statTime (통계 시간)
- apiName (API명)
- gwInstanceId (Gateway 인스턴스 ID)
- bizDivCode (업무구분코드)
- clientId (클라이언트 ID)
- inboundAdapter (Inbound Adapter)
- outboundAdapter (Outbound Adapter)
## 메트릭
### 건수 메트릭
- totalCnt: 총 처리 건수
- successCnt: 성공 건수
- timeoutCnt: Timeout 오류 건수
- systemErrCnt: 시스템 오류 건수
- bizErrCnt: 업무 오류 건수
- seq900TimeoutCnt: 로그시퀀스900 Timeout 건수
- seq900SystemErrCnt: 로그시퀀스900 시스템 오류 건수
- seq900BizErrCnt: 로그시퀀스900 업무 오류 건수
### 응답시간 메트릭 (ms)
- avgRespTime: 평균 응답시간
- minRespTime: 최소 응답시간
- maxRespTime: 최대 응답시간
- p50RespTime: 50 백분위 응답시간
- p95RespTime: 95 백분위 응답시간
## Multi-tenancy
API Gateway 통계는 `APIGW` 스키마에 저장됩니다.
Controller에서 `DataSourceContextHolder`를 사용하여 스키마를 전환합니다.
```java
DataSourceContextHolder.setDataSourceType(
DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW));
```
## 검색 조건
- 조회기간 (시작~종료)
- API명 (like 검색)
- Gateway 인스턴스 ID
- 업무구분코드 (like 검색)
- 클라이언트 ID (like 검색)
## 기술 스택
- JPA + QueryDSL
- MapStruct (Entity ↔ UI 매핑)
- Spring MVC (ResponseEntity)
- jqGrid (목록 화면)
@@ -0,0 +1,167 @@
# API Gateway 통계 화면
## 개요
API Gateway의 분단위/시간별/일별/월별/연도별 처리 통계를 조회하는 관리 화면입니다.
## 파일 구조
```
com.eactive.ext.kjb.statistics/
├── ApiStatsMinuteController.java # 분단위 통계 Controller
├── ApiStatsHourController.java # 시간별 통계 Controller
├── ApiStatsDayController.java # 일별 통계 Controller
├── ApiStatsMonthController.java # 월별 통계 Controller
├── ApiStatsYearController.java # 연도별 통계 Controller
├── ApiStatsAggregationController.java # 통계 집계 수동 실행 Controller
├── service/
│ └── ApiStatsExcelExportService.java # Excel 내보내기 Service
├── entity/
│ ├── ApiStatsMinute.java # 분단위 통계 Entity
│ ├── ApiStatsMinuteId.java # 분단위 통계 복합키
│ ├── ApiStatsHour.java # 시간별 통계 Entity
│ ├── ApiStatsHourId.java # 시간별 통계 복합키
│ ├── ApiStatsDay.java # 일별 통계 Entity
│ ├── ApiStatsDayId.java # 일별 통계 복합키
│ ├── ApiStatsMonth.java # 월별 통계 Entity
│ ├── ApiStatsMonthId.java # 월별 통계 복합키
│ ├── ApiStatsYear.java # 연도별 통계 Entity
│ └── ApiStatsYearId.java # 연도별 통계 복합키
├── repository/
│ ├── ApiStatsMinuteRepository.java
│ ├── ApiStatsHourRepository.java
│ ├── ApiStatsDayRepository.java
│ ├── ApiStatsMonthRepository.java
│ └── ApiStatsYearRepository.java
├── ui/
│ ├── ApiStatsUI.java # 통계 UI DTO
│ ├── ApiStatsSearch.java # 검색조건 DTO
│ └── ApiStatsChartUI.java # 차트 UI DTO
├── mapping/
│ └── ApiStatsUIMapper.java # Entity ↔ UI 매핑
└── README.md
JSP 화면:
WebContent/jsp/kjb/statistics/
├── apiStatsMinuteMan.jsp # 분단위 통계 화면
├── apiStatsHourMan.jsp # 시간별 통계 화면
├── apiStatsDayMan.jsp # 일별 통계 화면
├── apiStatsMonthMan.jsp # 월별 통계 화면
├── apiStatsYearMan.jsp # 연도별 통계 화면
└── apiStatsAggregationMan.jsp # 통계 집계 수동 실행 화면
```
## URL
| 화면 | URL |
|------|-----|
| 분단위 통계 | `/kjb/statistics/apiStatsMinuteMan.view` |
| 시간별 통계 | `/kjb/statistics/apiStatsHourMan.view` |
| 일별 통계 | `/kjb/statistics/apiStatsDayMan.view` |
| 월별 통계 | `/kjb/statistics/apiStatsMonthMan.view` |
| 연도별 통계 | `/kjb/statistics/apiStatsYearMan.view` |
| 통계 집계 실행 | `/kjb/statistics/apiStatsAggregationMan.view` |
## 테이블
| 테이블명 | 설명 |
|----------|------|
| API_STATS_MINUTE | 분단위 통계 |
| API_STATS_HOUR | 시간별 통계 |
| API_STATS_DAY | 일별 통계 |
| API_STATS_MONTH | 월별 통계 (STAT_TIME: YYYYMM 형식) |
| API_STATS_YEAR | 연도별 통계 (STAT_TIME: YYYY 형식) |
> 상세 테이블 스키마는 [statistics_table.md](statistics_table.md) 참조
## 복합키 구성
- statTime (통계 시간)
- apiName (API명)
- gwInstanceId (Gateway 인스턴스 ID)
- bizDivCode (업무구분코드)
- clientId (클라이언트 ID)
- inboundAdapter (Inbound Adapter)
- outboundAdapter (Outbound Adapter)
## 메트릭
### 건수 메트릭
- totalCnt: 총 처리 건수
- successCnt: 성공 건수
- timeoutCnt: Timeout 오류 건수
- systemErrCnt: 시스템 오류 건수
- bizErrCnt: 업무 오류 건수
- seq900TimeoutCnt: 로그시퀀스900 Timeout 건수
- seq900SystemErrCnt: 로그시퀀스900 시스템 오류 건수
- seq900BizErrCnt: 로그시퀀스900 업무 오류 건수
### 응답시간 메트릭 (ms)
- avgRespTime: 평균 응답시간
- minRespTime: 최소 응답시간
- maxRespTime: 최대 응답시간
- p50RespTime: 50 백분위 응답시간
- p95RespTime: 95 백분위 응답시간
## Multi-tenancy
API Gateway 통계는 `APIGW` 스키마에 저장됩니다.
Controller에서 `DataSourceContextHolder`를 사용하여 스키마를 전환합니다.
```java
DataSourceContextHolder.setDataSourceType(
DataSourceTypeManager.getDataSourceType(DataSourceTypeManager.APIGW));
```
## 검색 조건
- 조회기간 (시작~종료)
- API명 (like 검색)
- Gateway 인스턴스 ID
- 업무구분코드 (like 검색)
- 클라이언트 ID (like 검색)
## 주요 기능
- 통계 데이터 조회 (분/시간/일/월/년 단위)
- Excel 내보내기
- 차트 데이터 제공 (ApiStatsChartUI)
- 통계 집계 수동 실행 (시간→일, 일→월, 월→년)
## 통계 집계 수동 실행
통계 데이터는 스케줄러에 의해 자동으로 집계되지만, 누락된 기간이나 재집계가 필요한 경우 수동 실행할 수 있습니다.
### 집계 실행 화면 (apiStatsAggregationMan.jsp)
**접근 URL**: `/kjb/statistics/apiStatsAggregationMan.view`
한 화면에서 3가지 집계 작업을 모두 수동으로 실행할 수 있습니다:
1. **시간별 → 일별 집계**
- API 엔드포인트: `POST /onl/kjb/statistics/apiStatsDayMan.json?cmd=AGGREGATION`
- 파라미터: `targetDate` (yyyyMMdd 형식, 예: 20250120)
- 기본값: 전일 날짜
2. **일별 → 월별 집계**
- API 엔드포인트: `POST /onl/kjb/statistics/apiStatsMonthMan.json?cmd=AGGREGATION`
- 파라미터: `targetMonth` (yyyyMM 형식, 예: 202501)
- 기본값: 전월
3. **월별 → 연별 집계**
- API 엔드포인트: `POST /onl/kjb/statistics/apiStatsYearMan.json?cmd=AGGREGATION`
- 파라미터: `targetYear` (yyyy 형식, 예: 2024)
- 기본값: 전년
**주의사항**:
- 집계 대상 기간의 원본 데이터가 존재해야 정상 집계됩니다.
- 입력값을 비워두면 기본값(전일/전월/전년)으로 실행됩니다.
- 실행 결과는 처리 건수와 함께 화면에 표시됩니다.
## 기술 스택
- JPA + QueryDSL
- MapStruct (Entity ↔ UI 매핑)
- Spring MVC (ResponseEntity)
- jqGrid (목록 화면)
- Apache POI (Excel 내보내기)
@@ -0,0 +1,152 @@
package com.eactive.ext.kjb.statistics.job;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonth;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsMonth;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
/**
* 일별 통계를 월별 통계로 집계하는 배치 작업
* API_STATS_DAY → API_STATS_MONTH
* 실행 주기: 매월 1일 00:20 (전월 데이터 집계)
*/
@Component
public class DailyToMonthlyAggregationJob implements Job {
private static final Logger log = LoggerFactory.getLogger(DailyToMonthlyAggregationJob.class);
private ApiStatsDayService apiStatsDayService;
private ApiStatsMonthService apiStatsMonthService;
@Autowired
public void setApiStatsDayService(ApiStatsDayService apiStatsDayService) {
this.apiStatsDayService = apiStatsDayService;
}
@Autowired
public void setApiStatsMonthService(ApiStatsMonthService apiStatsMonthService) {
this.apiStatsMonthService = apiStatsMonthService;
}
@Override
@Transactional
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
YearMonth targetMonth = YearMonth.now().minusMonths(1);
executeManual(targetMonth);
} catch (Exception e) {
throw new JobExecutionException(e);
}
}
/**
* 수동 실행 메서드 (월 지정)
*/
@Transactional
public int executeManual(YearMonth targetMonth) {
long startTime = System.currentTimeMillis();
log.info("=== 일별→월별 통계 집계 작업 시작 ===");
LocalDate startDate = targetMonth.atDay(1);
LocalDate endDate = targetMonth.atEndOfMonth();
log.info("집계 대상 기간: {} ~ {}", startDate, endDate);
List<ApiStatsMonth> aggregatedData = aggregateDailyToMonthly(startDate, endDate, targetMonth);
// 1. 기존 데이터 삭제 (QueryDSL 사용)
JPAQueryFactory queryFactory = apiStatsMonthService.getJPAQueryFactory();
QApiStatsMonth qm = QApiStatsMonth.apiStatsMonth;
String targetMonthStr = targetMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
long deletedCount = queryFactory
.delete(qm)
.where(qm.statTime.eq(targetMonthStr))
.execute();
log.info("기존 데이터 삭제: {} 건", deletedCount);
// 2. 배치 INSERT
apiStatsMonthService.saveAll(aggregatedData);
int savedCount = aggregatedData.size();
long elapsedTime = System.currentTimeMillis() - startTime;
log.info("=== 일별→월별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime);
return savedCount;
}
/**
* 일별 데이터를 월별로 집계
*/
private List<ApiStatsMonth> aggregateDailyToMonthly(LocalDate startDate, LocalDate endDate, YearMonth targetMonth) {
QApiStatsDay qd = QApiStatsDay.apiStatsDay;
JPAQueryFactory queryFactory = apiStatsDayService.getJPAQueryFactory();
String targetMonthStr = targetMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
List<ApiStatsMonth> aggregatedData = queryFactory
.select(Projections.bean(ApiStatsMonth.class,
qd.apiName,
qd.gwInstanceId,
qd.bizDivCode,
qd.clientId,
qd.inboundAdapter,
qd.outboundAdapter,
qd.totalCnt.sum().as("totalCnt"),
qd.successCnt.sum().as("successCnt"),
qd.timeoutCnt.sum().as("timeoutCnt"),
qd.systemErrCnt.sum().as("systemErrCnt"),
qd.bizErrCnt.sum().as("bizErrCnt"),
qd.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"),
qd.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"),
qd.seq900BizErrCnt.sum().as("seq900BizErrCnt"),
qd.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"),
qd.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"),
qd.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"),
qd.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"),
qd.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime")))
.from(qd)
.where(qd.statTime.goe(startDate)
.and(qd.statTime.loe(endDate)))
.groupBy(qd.apiName, qd.gwInstanceId, qd.bizDivCode, qd.clientId, qd.inboundAdapter, qd.outboundAdapter)
.fetch();
for (ApiStatsMonth statsMonth : aggregatedData) {
statsMonth.setStatTime(targetMonthStr);
if (statsMonth.getAvgRespTime() != null) {
statsMonth.setAvgRespTime(statsMonth.getAvgRespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsMonth.getP50RespTime() != null) {
statsMonth.setP50RespTime(statsMonth.getP50RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsMonth.getP95RespTime() != null) {
statsMonth.setP95RespTime(statsMonth.getP95RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsMonth.getMinRespTime() == null) {
statsMonth.setMinRespTime(BigDecimal.ZERO);
}
if (statsMonth.getMaxRespTime() == null) {
statsMonth.setMaxRespTime(BigDecimal.ZERO);
}
}
return aggregatedData;
}
}
@@ -0,0 +1,148 @@
package com.eactive.ext.kjb.statistics.job;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDayService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHourService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsHour;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
/**
* 시간별 통계를 일별 통계로 집계하는 배치 작업
* API_STATS_HOUR → API_STATS_DAY
* 실행 주기: 매일 00:10 (전일 데이터 집계)
*/
@Component
public class HourlyToDailyAggregationJob implements Job {
private static final Logger log = LoggerFactory.getLogger(HourlyToDailyAggregationJob.class);
private ApiStatsHourService apiStatsHourService;
private ApiStatsDayService apiStatsDayService;
@Autowired
public void setApiStatsHourService(ApiStatsHourService apiStatsHourService) {
this.apiStatsHourService = apiStatsHourService;
}
@Autowired
public void setApiStatsDayService(ApiStatsDayService apiStatsDayService) {
this.apiStatsDayService = apiStatsDayService;
}
@Override
@Transactional
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
LocalDate targetDate = LocalDate.now().minusDays(1);
executeManual(targetDate);
} catch (Exception e) {
throw new JobExecutionException(e);
}
}
/**
* 수동 실행 메서드 (날짜 지정)
*/
@Transactional
public int executeManual(LocalDate targetDate) {
long startTime = System.currentTimeMillis();
log.info("=== 시간별→일별 통계 집계 작업 시작 ===");
LocalDateTime startDateTime = targetDate.atStartOfDay();
LocalDateTime endDateTime = targetDate.plusDays(1).atStartOfDay().minusSeconds(1);
log.info("집계 대상 기간: {} ~ {}", startDateTime, endDateTime);
List<ApiStatsDay> aggregatedData = aggregateHourlyToDaily(startDateTime, endDateTime, targetDate);
// 1. 기존 데이터 삭제 (QueryDSL 사용)
JPAQueryFactory queryFactory = apiStatsDayService.getJPAQueryFactory();
QApiStatsDay qd = QApiStatsDay.apiStatsDay;
long deletedCount = queryFactory
.delete(qd)
.where(qd.statTime.eq(targetDate))
.execute();
log.info("기존 데이터 삭제: {} 건", deletedCount);
// 2. 배치 INSERT
apiStatsDayService.saveAll(aggregatedData);
int savedCount = aggregatedData.size();
long elapsedTime = System.currentTimeMillis() - startTime;
log.info("=== 시간별→일별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime);
return savedCount;
}
/**
* 시간별 데이터를 일별로 집계
*/
private List<ApiStatsDay> aggregateHourlyToDaily(LocalDateTime startDateTime, LocalDateTime endDateTime, LocalDate targetDate) {
QApiStatsHour qh = QApiStatsHour.apiStatsHour;
JPAQueryFactory queryFactory = apiStatsHourService.getJPAQueryFactory();
List<ApiStatsDay> aggregatedData = queryFactory
.select(Projections.bean(ApiStatsDay.class,
qh.apiName,
qh.gwInstanceId,
qh.bizDivCode,
qh.clientId,
qh.inboundAdapter,
qh.outboundAdapter,
qh.totalCnt.sum().as("totalCnt"),
qh.successCnt.sum().as("successCnt"),
qh.timeoutCnt.sum().as("timeoutCnt"),
qh.systemErrCnt.sum().as("systemErrCnt"),
qh.bizErrCnt.sum().as("bizErrCnt"),
qh.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"),
qh.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"),
qh.seq900BizErrCnt.sum().as("seq900BizErrCnt"),
qh.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"),
qh.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"),
qh.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"),
qh.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"),
qh.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime")))
.from(qh)
.where(qh.statTime.goe(startDateTime)
.and(qh.statTime.loe(endDateTime)))
.groupBy(qh.apiName, qh.gwInstanceId, qh.bizDivCode, qh.clientId, qh.inboundAdapter, qh.outboundAdapter)
.fetch();
for (ApiStatsDay statsDay : aggregatedData) {
statsDay.setStatTime(targetDate);
if (statsDay.getAvgRespTime() != null) {
statsDay.setAvgRespTime(statsDay.getAvgRespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsDay.getP50RespTime() != null) {
statsDay.setP50RespTime(statsDay.getP50RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsDay.getP95RespTime() != null) {
statsDay.setP95RespTime(statsDay.getP95RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsDay.getMinRespTime() == null) {
statsDay.setMinRespTime(BigDecimal.ZERO);
}
if (statsDay.getMaxRespTime() == null) {
statsDay.setMaxRespTime(BigDecimal.ZERO);
}
}
return aggregatedData;
}
}
@@ -0,0 +1,150 @@
package com.eactive.ext.kjb.statistics.job;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Year;
import java.util.List;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYear;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYearService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonthService;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsMonth;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.QApiStatsYear;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
/**
* 월별 통계를 연별 통계로 집계하는 배치 작업
* API_STATS_MONTH → API_STATS_YEAR
* 실행 주기: 매년 1월 1일 00:30 (전년 데이터 집계)
*/
@Component
public class MonthlyToYearlyAggregationJob implements Job {
private static final Logger log = LoggerFactory.getLogger(MonthlyToYearlyAggregationJob.class);
private ApiStatsMonthService apiStatsMonthService;
private ApiStatsYearService apiStatsYearService;
@Autowired
public void setApiStatsMonthService(ApiStatsMonthService apiStatsMonthService) {
this.apiStatsMonthService = apiStatsMonthService;
}
@Autowired
public void setApiStatsYearService(ApiStatsYearService apiStatsYearService) {
this.apiStatsYearService = apiStatsYearService;
}
@Override
@Transactional
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
Year targetYear = Year.now().minusYears(1);
executeManual(targetYear);
} catch (Exception e) {
throw new JobExecutionException(e);
}
}
/**
* 수동 실행 메서드 (연도 지정)
*/
@Transactional
public int executeManual(Year targetYear) {
long startTime = System.currentTimeMillis();
log.info("=== 월별→연별 통계 집계 작업 시작 ===");
String startMonth = targetYear.toString() + "01";
String endMonth = targetYear.toString() + "12";
log.info("집계 대상 기간: {} ~ {}", startMonth, endMonth);
List<ApiStatsYear> aggregatedData = aggregateMonthlyToYearly(startMonth, endMonth, targetYear);
// 1. 기존 데이터 삭제 (QueryDSL 사용)
JPAQueryFactory queryFactory = apiStatsYearService.getJPAQueryFactory();
QApiStatsYear qy = QApiStatsYear.apiStatsYear;
String targetYearStr = targetYear.toString();
long deletedCount = queryFactory
.delete(qy)
.where(qy.statTime.eq(targetYearStr))
.execute();
log.info("기존 데이터 삭제: {} 건", deletedCount);
// 2. 배치 INSERT
apiStatsYearService.saveAll(aggregatedData);
int savedCount = aggregatedData.size();
long elapsedTime = System.currentTimeMillis() - startTime;
log.info("=== 월별→연별 통계 집계 작업 완료 === (처리 건수: {}, 소요 시간: {}ms)", savedCount, elapsedTime);
return savedCount;
}
/**
* 월별 데이터를 연별로 집계
*/
private List<ApiStatsYear> aggregateMonthlyToYearly(String startMonth, String endMonth, Year targetYear) {
QApiStatsMonth qm = QApiStatsMonth.apiStatsMonth;
JPAQueryFactory queryFactory = apiStatsMonthService.getJPAQueryFactory();
String targetYearStr = targetYear.toString();
List<ApiStatsYear> aggregatedData = queryFactory
.select(Projections.bean(ApiStatsYear.class,
qm.apiName,
qm.gwInstanceId,
qm.bizDivCode,
qm.clientId,
qm.inboundAdapter,
qm.outboundAdapter,
qm.totalCnt.sum().as("totalCnt"),
qm.successCnt.sum().as("successCnt"),
qm.timeoutCnt.sum().as("timeoutCnt"),
qm.systemErrCnt.sum().as("systemErrCnt"),
qm.bizErrCnt.sum().as("bizErrCnt"),
qm.seq900TimeoutCnt.sum().as("seq900TimeoutCnt"),
qm.seq900SystemErrCnt.sum().as("seq900SystemErrCnt"),
qm.seq900BizErrCnt.sum().as("seq900BizErrCnt"),
qm.avgRespTime.avg().castToNum(BigDecimal.class).as("avgRespTime"),
qm.minRespTime.min().castToNum(BigDecimal.class).as("minRespTime"),
qm.maxRespTime.max().castToNum(BigDecimal.class).as("maxRespTime"),
qm.p50RespTime.avg().castToNum(BigDecimal.class).as("p50RespTime"),
qm.p95RespTime.avg().castToNum(BigDecimal.class).as("p95RespTime")))
.from(qm)
.where(qm.statTime.goe(startMonth)
.and(qm.statTime.loe(endMonth)))
.groupBy(qm.apiName, qm.gwInstanceId, qm.bizDivCode, qm.clientId, qm.inboundAdapter, qm.outboundAdapter)
.fetch();
for (ApiStatsYear statsYear : aggregatedData) {
statsYear.setStatTime(targetYearStr);
if (statsYear.getAvgRespTime() != null) {
statsYear.setAvgRespTime(statsYear.getAvgRespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsYear.getP50RespTime() != null) {
statsYear.setP50RespTime(statsYear.getP50RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsYear.getP95RespTime() != null) {
statsYear.setP95RespTime(statsYear.getP95RespTime().setScale(3, RoundingMode.HALF_UP));
}
if (statsYear.getMinRespTime() == null) {
statsYear.setMinRespTime(BigDecimal.ZERO);
}
if (statsYear.getMaxRespTime() == null) {
statsYear.setMaxRespTime(BigDecimal.ZERO);
}
}
return aggregatedData;
}
}
@@ -1,5 +1,6 @@
package com.eactive.ext.kjb.statistics.mapping; package com.eactive.ext.kjb.statistics.mapping;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -8,8 +9,11 @@ import org.mapstruct.Mapping;
import org.mapstruct.Named; import org.mapstruct.Named;
import com.eactive.eai.data.mapper.BaseMapperConfig; import com.eactive.eai.data.mapper.BaseMapperConfig;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsDay;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHour; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsHour;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMinute; import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMinute;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsMonth;
import com.eactive.eai.rms.data.entity.onl.kjb.statistics.ApiStatsYear;
import com.eactive.ext.kjb.statistics.ui.ApiStatsUI; import com.eactive.ext.kjb.statistics.ui.ApiStatsUI;
@Mapper(config = BaseMapperConfig.class) @Mapper(config = BaseMapperConfig.class)
@@ -17,6 +21,7 @@ public interface ApiStatsUIMapper {
DateTimeFormatter MINUTE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); DateTimeFormatter MINUTE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
DateTimeFormatter HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00"); DateTimeFormatter HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00");
DateTimeFormatter DAY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatMinuteTime") @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatMinuteTime")
ApiStatsUI toVo(ApiStatsMinute entity); ApiStatsUI toVo(ApiStatsMinute entity);
@@ -24,6 +29,15 @@ public interface ApiStatsUIMapper {
@Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatHourTime") @Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatHourTime")
ApiStatsUI toVo(ApiStatsHour entity); ApiStatsUI toVo(ApiStatsHour entity);
@Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatDayTime")
ApiStatsUI toVo(ApiStatsDay entity);
@Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatMonthTime")
ApiStatsUI toVo(ApiStatsMonth entity);
@Mapping(source = "statTime", target = "statTime", qualifiedByName = "formatYearTime")
ApiStatsUI toVo(ApiStatsYear entity);
@Named("formatMinuteTime") @Named("formatMinuteTime")
default String formatMinuteTime(LocalDateTime dateTime) { default String formatMinuteTime(LocalDateTime dateTime) {
if (dateTime == null) { if (dateTime == null) {
@@ -39,4 +53,25 @@ public interface ApiStatsUIMapper {
} }
return dateTime.format(HOUR_FORMATTER); return dateTime.format(HOUR_FORMATTER);
} }
@Named("formatDayTime")
default String formatDayTime(LocalDate date) {
if (date == null) {
return null;
}
return date.format(DAY_FORMATTER);
}
@Named("formatMonthTime")
default String formatMonthTime(String yyyymm) {
if (yyyymm == null || yyyymm.length() != 6) {
return yyyymm;
}
return yyyymm.substring(0, 4) + "-" + yyyymm.substring(4, 6);
}
@Named("formatYearTime")
default String formatYearTime(String yyyy) {
return yyyy;
}
} }
@@ -167,3 +167,225 @@ COMMENT ON COLUMN API_STATS_HOUR.P95_RESP_TIME IS '95 백분위 응답시간(ms)
--- ---
## 일별 통계 테이블 DDL
```sql
-- API Gateway 일별 통계 테이블
CREATE TABLE API_STATS_DAY (
STAT_TIME DATE NOT NULL, -- 통계 시간 (일 단위)
API_NAME VARCHAR2(100) NOT NULL, -- API명
GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID
BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드
CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID
INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter
OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter
-- 건수 메트릭
TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL,
TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 로그 시퀀스 900 구간 에러 카운트
SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 응답시간 메트릭 (ms, 소수점 3자리)
AVG_RESP_TIME NUMBER(10,3) DEFAULT 0,
MIN_RESP_TIME NUMBER(10,3) DEFAULT 0,
MAX_RESP_TIME NUMBER(10,3) DEFAULT 0,
P50_RESP_TIME NUMBER(10,3) DEFAULT 0,
P95_RESP_TIME NUMBER(10,3) DEFAULT 0,
CONSTRAINT PK_API_STATS_DAY PRIMARY KEY (
STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER
)
);
-- 코멘트
COMMENT ON TABLE API_STATS_DAY IS 'API Gateway 일별 처리 통계';
COMMENT ON COLUMN API_STATS_DAY.STAT_TIME IS '통계시간(일단위)';
COMMENT ON COLUMN API_STATS_DAY.API_NAME IS 'API 명칭';
COMMENT ON COLUMN API_STATS_DAY.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID';
COMMENT ON COLUMN API_STATS_DAY.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)';
COMMENT ON COLUMN API_STATS_DAY.CLIENT_ID IS '클라이언트 ID (없으면 NONE)';
COMMENT ON COLUMN API_STATS_DAY.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_DAY.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_DAY.TOTAL_CNT IS '총 처리 건수';
COMMENT ON COLUMN API_STATS_DAY.SUCCESS_CNT IS '성공 건수';
COMMENT ON COLUMN API_STATS_DAY.TIMEOUT_CNT IS 'Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.SYSTEM_ERR_CNT IS '시스템 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.BIZ_ERR_CNT IS '업무 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수';
COMMENT ON COLUMN API_STATS_DAY.AVG_RESP_TIME IS '평균 응답시간(ms)';
COMMENT ON COLUMN API_STATS_DAY.MIN_RESP_TIME IS '최소 응답시간(ms)';
COMMENT ON COLUMN API_STATS_DAY.MAX_RESP_TIME IS '최대 응답시간(ms)';
COMMENT ON COLUMN API_STATS_DAY.P50_RESP_TIME IS '50 백분위 응답시간(ms)';
COMMENT ON COLUMN API_STATS_DAY.P95_RESP_TIME IS '95 백분위 응답시간(ms)';
```
---
## 월별 통계 테이블 DDL
```sql
-- API Gateway 월별 통계 테이블
CREATE TABLE API_STATS_MONTH (
STAT_TIME VARCHAR2(6) NOT NULL, -- 통계 시간 (YYYYMM 형식)
API_NAME VARCHAR2(100) NOT NULL, -- API명
GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID
BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드
CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID
INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter
OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter
-- 건수 메트릭
TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL,
TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 로그 시퀀스 900 구간 에러 카운트
SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 응답시간 메트릭 (ms, 소수점 3자리)
AVG_RESP_TIME NUMBER(10,3) DEFAULT 0,
MIN_RESP_TIME NUMBER(10,3) DEFAULT 0,
MAX_RESP_TIME NUMBER(10,3) DEFAULT 0,
P50_RESP_TIME NUMBER(10,3) DEFAULT 0,
P95_RESP_TIME NUMBER(10,3) DEFAULT 0,
CONSTRAINT PK_API_STATS_MONTH PRIMARY KEY (
STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER
)
);
-- 코멘트
COMMENT ON TABLE API_STATS_MONTH IS 'API Gateway 월별 처리 통계';
COMMENT ON COLUMN API_STATS_MONTH.STAT_TIME IS '통계시간(월단위, YYYYMM 형식)';
COMMENT ON COLUMN API_STATS_MONTH.API_NAME IS 'API 명칭';
COMMENT ON COLUMN API_STATS_MONTH.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID';
COMMENT ON COLUMN API_STATS_MONTH.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)';
COMMENT ON COLUMN API_STATS_MONTH.CLIENT_ID IS '클라이언트 ID (없으면 NONE)';
COMMENT ON COLUMN API_STATS_MONTH.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_MONTH.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_MONTH.TOTAL_CNT IS '총 처리 건수';
COMMENT ON COLUMN API_STATS_MONTH.SUCCESS_CNT IS '성공 건수';
COMMENT ON COLUMN API_STATS_MONTH.TIMEOUT_CNT IS 'Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.SYSTEM_ERR_CNT IS '시스템 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.BIZ_ERR_CNT IS '업무 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수';
COMMENT ON COLUMN API_STATS_MONTH.AVG_RESP_TIME IS '평균 응답시간(ms)';
COMMENT ON COLUMN API_STATS_MONTH.MIN_RESP_TIME IS '최소 응답시간(ms)';
COMMENT ON COLUMN API_STATS_MONTH.MAX_RESP_TIME IS '최대 응답시간(ms)';
COMMENT ON COLUMN API_STATS_MONTH.P50_RESP_TIME IS '50 백분위 응답시간(ms)';
COMMENT ON COLUMN API_STATS_MONTH.P95_RESP_TIME IS '95 백분위 응답시간(ms)';
```
---
## 연도별 통계 테이블 DDL
```sql
-- API Gateway 연도별 통계 테이블
CREATE TABLE API_STATS_YEAR (
STAT_TIME VARCHAR2(4) NOT NULL, -- 통계 시간 (YYYY 형식)
API_NAME VARCHAR2(100) NOT NULL, -- API명
GW_INSTANCE_ID VARCHAR2(50) NOT NULL, -- API Gateway 인스턴스 ID
BIZ_DIV_CODE VARCHAR2(50) DEFAULT 'NONE' NOT NULL, -- 업무구분코드
CLIENT_ID VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- 클라이언트 ID
INBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Inbound Adapter
OUTBOUND_ADAPTER VARCHAR2(100) DEFAULT 'NONE' NOT NULL, -- Outbound Adapter
-- 건수 메트릭
TOTAL_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SUCCESS_CNT NUMBER(12) DEFAULT 0 NOT NULL,
TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 로그 시퀀스 900 구간 에러 카운트
SEQ900_TIMEOUT_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_SYSTEM_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
SEQ900_BIZ_ERR_CNT NUMBER(12) DEFAULT 0 NOT NULL,
-- 응답시간 메트릭 (ms, 소수점 3자리)
AVG_RESP_TIME NUMBER(10,3) DEFAULT 0,
MIN_RESP_TIME NUMBER(10,3) DEFAULT 0,
MAX_RESP_TIME NUMBER(10,3) DEFAULT 0,
P50_RESP_TIME NUMBER(10,3) DEFAULT 0,
P95_RESP_TIME NUMBER(10,3) DEFAULT 0,
CONSTRAINT PK_API_STATS_YEAR PRIMARY KEY (
STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID, INBOUND_ADAPTER, OUTBOUND_ADAPTER
)
);
-- 코멘트
COMMENT ON TABLE API_STATS_YEAR IS 'API Gateway 연도별 처리 통계';
COMMENT ON COLUMN API_STATS_YEAR.STAT_TIME IS '통계시간(연도단위, YYYY 형식)';
COMMENT ON COLUMN API_STATS_YEAR.API_NAME IS 'API 명칭';
COMMENT ON COLUMN API_STATS_YEAR.GW_INSTANCE_ID IS 'API Gateway 인스턴스 ID';
COMMENT ON COLUMN API_STATS_YEAR.BIZ_DIV_CODE IS '업무구분코드 (없으면 NONE)';
COMMENT ON COLUMN API_STATS_YEAR.CLIENT_ID IS '클라이언트 ID (없으면 NONE)';
COMMENT ON COLUMN API_STATS_YEAR.INBOUND_ADAPTER IS 'Inbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_YEAR.OUTBOUND_ADAPTER IS 'Outbound Adapter (없으면 NONE)';
COMMENT ON COLUMN API_STATS_YEAR.TOTAL_CNT IS '총 처리 건수';
COMMENT ON COLUMN API_STATS_YEAR.SUCCESS_CNT IS '성공 건수';
COMMENT ON COLUMN API_STATS_YEAR.TIMEOUT_CNT IS 'Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.SYSTEM_ERR_CNT IS '시스템 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.BIZ_ERR_CNT IS '업무 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.SEQ900_TIMEOUT_CNT IS '로그시퀀스900 Timeout 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.SEQ900_SYSTEM_ERR_CNT IS '로그시퀀스900 시스템 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.SEQ900_BIZ_ERR_CNT IS '로그시퀀스900 업무 오류 건수';
COMMENT ON COLUMN API_STATS_YEAR.AVG_RESP_TIME IS '평균 응답시간(ms)';
COMMENT ON COLUMN API_STATS_YEAR.MIN_RESP_TIME IS '최소 응답시간(ms)';
COMMENT ON COLUMN API_STATS_YEAR.MAX_RESP_TIME IS '최대 응답시간(ms)';
COMMENT ON COLUMN API_STATS_YEAR.P50_RESP_TIME IS '50 백분위 응답시간(ms)';
COMMENT ON COLUMN API_STATS_YEAR.P95_RESP_TIME IS '95 백분위 응답시간(ms)';
```
---
## 샘플 데이터 (테스트용)
```sql
-- 월별 통계 샘플 데이터
INSERT INTO API_STATS_MONTH (
STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID,
INBOUND_ADAPTER, OUTBOUND_ADAPTER, TOTAL_CNT, SUCCESS_CNT,
TIMEOUT_CNT, SYSTEM_ERR_CNT, BIZ_ERR_CNT,
SEQ900_TIMEOUT_CNT, SEQ900_SYSTEM_ERR_CNT, SEQ900_BIZ_ERR_CNT,
AVG_RESP_TIME, MIN_RESP_TIME, MAX_RESP_TIME, P50_RESP_TIME, P95_RESP_TIME
) VALUES (
'202512', 'TEST_API', 'GW01', 'MOBILE', 'CLIENT01',
'HTTP', 'REST', 30000, 29400, 300, 150, 150, 60, 30, 60,
120.500, 8.000, 600.000, 95.000, 280.000
);
-- 연도별 통계 샘플 데이터
INSERT INTO API_STATS_YEAR (
STAT_TIME, API_NAME, GW_INSTANCE_ID, BIZ_DIV_CODE, CLIENT_ID,
INBOUND_ADAPTER, OUTBOUND_ADAPTER, TOTAL_CNT, SUCCESS_CNT,
TIMEOUT_CNT, SYSTEM_ERR_CNT, BIZ_ERR_CNT,
SEQ900_TIMEOUT_CNT, SEQ900_SYSTEM_ERR_CNT, SEQ900_BIZ_ERR_CNT,
AVG_RESP_TIME, MIN_RESP_TIME, MAX_RESP_TIME, P50_RESP_TIME, P95_RESP_TIME
) VALUES (
'2025', 'TEST_API', 'GW01', 'MOBILE', 'CLIENT01',
'HTTP', 'REST', 365000, 357700, 3650, 1825, 1825, 730, 365, 730,
118.750, 5.000, 700.000, 90.000, 270.000
);
COMMIT;
```
---
@@ -1,6 +1,7 @@
package com.eactive.ext.kjb.statistics.ui; package com.eactive.ext.kjb.statistics.ui;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -30,12 +31,43 @@ public class ApiStatsChartUI {
private BigDecimal p50RespTime; private BigDecimal p50RespTime;
private BigDecimal p95RespTime; private BigDecimal p95RespTime;
// QueryDSL Projections용 생성자 // QueryDSL Projections용 생성자 - Hour/Minute (LocalDateTime)
public ApiStatsChartUI(LocalDateTime statTime, Long totalCnt, Long successCnt, public ApiStatsChartUI(LocalDateTime statTime, Long totalCnt, Long successCnt,
Long timeoutCnt, Long systemErrCnt, Long bizErrCnt, Long timeoutCnt, Long systemErrCnt, Long bizErrCnt,
Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt, Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt,
Double avgRespTime, Double p50RespTime, Double p95RespTime) { Double avgRespTime, Double p50RespTime, Double p95RespTime) {
this.statTime = statTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); this.statTime = statTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"));
setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt,
seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt,
avgRespTime, p50RespTime, p95RespTime);
}
// QueryDSL Projections용 생성자 - Day (LocalDate)
public ApiStatsChartUI(LocalDate statTime, Long totalCnt, Long successCnt,
Long timeoutCnt, Long systemErrCnt, Long bizErrCnt,
Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt,
Double avgRespTime, Double p50RespTime, Double p95RespTime) {
this.statTime = statTime.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt,
seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt,
avgRespTime, p50RespTime, p95RespTime);
}
// QueryDSL Projections용 생성자 - Month/Year (String)
public ApiStatsChartUI(String statTime, Long totalCnt, Long successCnt,
Long timeoutCnt, Long systemErrCnt, Long bizErrCnt,
Long seq900TimeoutCnt, Long seq900SystemErrCnt, Long seq900BizErrCnt,
Double avgRespTime, Double p50RespTime, Double p95RespTime) {
this.statTime = statTime;
setMetrics(totalCnt, successCnt, timeoutCnt, systemErrCnt, bizErrCnt,
seq900TimeoutCnt, seq900SystemErrCnt, seq900BizErrCnt,
avgRespTime, p50RespTime, p95RespTime);
}
private void setMetrics(Long totalCnt, Long successCnt, Long timeoutCnt,
Long systemErrCnt, Long bizErrCnt, Long seq900TimeoutCnt,
Long seq900SystemErrCnt, Long seq900BizErrCnt,
Double avgRespTime, Double p50RespTime, Double p95RespTime) {
this.totalCnt = totalCnt != null ? totalCnt : 0L; this.totalCnt = totalCnt != null ? totalCnt : 0L;
this.successCnt = successCnt != null ? successCnt : 0L; this.successCnt = successCnt != null ? successCnt : 0L;
this.timeoutCnt = timeoutCnt != null ? timeoutCnt : 0L; this.timeoutCnt = timeoutCnt != null ? timeoutCnt : 0L;
@@ -1,5 +1,6 @@
package com.eactive.ext.kjb.statistics.ui; package com.eactive.ext.kjb.statistics.ui;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Data; import lombok.Data;
@@ -22,4 +23,19 @@ public class ApiStatsSearch {
private LocalDateTime parsedStartDateTime; private LocalDateTime parsedStartDateTime;
/** 파싱된 종료시간 */ /** 파싱된 종료시간 */
private LocalDateTime parsedEndDateTime; private LocalDateTime parsedEndDateTime;
/** 파싱된 시작날짜 (Day 통계용) */
private LocalDate parsedStartDate;
/** 파싱된 종료날짜 (Day 통계용) */
private LocalDate parsedEndDate;
/** 파싱된 시작월 (Month 통계용, YYYYMM) */
private String parsedStartMonth;
/** 파싱된 종료월 (Month 통계용, YYYYMM) */
private String parsedEndMonth;
/** 파싱된 시작년 (Year 통계용, YYYY) */
private String parsedStartYear;
/** 파싱된 종료년 (Year 통계용, YYYY) */
private String parsedEndYear;
} }