작업 기간: 2025-12-30
작업 브랜치: feature/#381
주요 작업: KPI 대시보드 시각화 문제 12건 수정 + 질문 추천 KPI 수집 로직 버그 수정
문제: HTML element ID 불일치로 데이터 표시 안됨
해결: signalAcceptanceRate ID 매칭 및 실시간 계산 로직 추가
문제: Chart.js 캔버스 ID가 존재하지만 렌더링 안됨
해결: signalChart 캔버스에 대한 차트 생성 로직 구현
문제: 더미 데이터 고정값 사용 해결: API 데이터 기반 실시간 계산
const activeChatrooms = kpiData.activeChatroomsSum;
const firstMessageCount = Math.round(activeChatrooms * kpiData.firstMessageRateAvg / 100);
const threeTurnCount = Math.round(activeChatrooms * kpiData.threeTurnRateAvg / 100);
const revisitCount = Math.round(activeChatrooms * kpiData.chatReturnRateAvg / 100);문제: 퍼센티지 계산 미적용
해결: chatActivityRateAvg 값을 올바르게 표시
문제: 차트 캔버스 ID 불일치 (chatroomChart vs chatroomActivityChart)
해결: ID 통일 및 막대 그래프 렌더링
문제: 차트 캔버스 ID 불일치 (chatRateChart vs chatConversionChart)
해결: ID 통일 및 꺾은선 그래프 렌더링 (FMR, 3턴, CRR)
문제: 하드코딩된 값과 진행률 바 미동작 해결:
- 백엔드에 질문 사용/미사용별 평균 메시지 수 필드 추가
- 동적 바 차트 구현
document.getElementById('avgMsgValue').textContent = avgMsg.toFixed(1);
document.getElementById('avgMsgBar').style.width = ((avgMsg / maxMsg) * 100) + '%';문제: 질문 사용 채팅방 수 등 고정값 해결: API 데이터 매핑 및 실시간 업데이트
문제: 차트 캔버스 ID 불일치 (questionChart vs questionClickChart)
해결: ID 통일 및 막대 그래프 렌더링
문제: HTML element ID 불일치
codeRequestSum→codeUnlockRequestSumcodeApprovedSum→codeUnlockApprovedSumcodeApprovalRate→codeUnlockApprovalRate
해결: ID 통일 및 API 데이터 연동
문제: avgChatDuration ID 불일치
해결: avgChatDurationDays로 변경 및 "일" 단위 표시
문제: 프로필 대표 질문 통계 기능 없음 해결:
- 백엔드 API 엔드포인트 생성 (
/v1/admin/kpi/question-insights) - TOP 10 인기 질문 테이블 렌더링
- 카테고리별 분포 파이 차트 구현
마이그레이션: V19__add_question_comparison_fields_to_daily_kpi.sql
ALTER TABLE daily_kpi
ADD COLUMN question_used_avg_message_count DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
ADD COLUMN question_not_used_avg_message_count DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
ADD COLUMN question_used_three_turn_rate DECIMAL(5,2) DEFAULT 0.00 NOT NULL,
ADD COLUMN question_not_used_three_turn_rate DECIMAL(5,2) DEFAULT 0.00 NOT NULL,
ADD COLUMN question_used_chat_return_rate DECIMAL(5,2) DEFAULT 0.00 NOT NULL,
ADD COLUMN question_not_used_chat_return_rate DECIMAL(5,2) DEFAULT 0.00 NOT NULL;DailyKpi.kt - 질문 비교 메트릭 필드 추가
// 3. 질문추천 KPI
var questionClickCount: Int = 0,
var questionUsedChatroomsCount: Int = 0,
var questionUsedAvgMessageCount: BigDecimal = BigDecimal.ZERO,
var questionNotUsedAvgMessageCount: BigDecimal = BigDecimal.ZERO,
var questionUsedThreeTurnRate: BigDecimal = BigDecimal.ZERO,
var questionNotUsedThreeTurnRate: BigDecimal = BigDecimal.ZERO,
var questionUsedChatReturnRate: BigDecimal = BigDecimal.ZERO,
var questionNotUsedChatReturnRate: BigDecimal = BigDecimal.ZERO,KpiBatchService.kt - aggregateQuestionKpi() 메서드 확장
private fun aggregateQuestionKpi(
dailyKpi: DailyKpi,
utcStart: LocalDateTime,
utcEnd: LocalDateTime
) {
// 기존: 질문 클릭 수, 사용 채팅방 수 집계
// 신규: 질문 사용/미사용 채팅방 성과 비교
val createdChatRooms = kpiChatRepository.findByCreatedAtBetween(utcStart, utcEnd)
val questionUsedChatRoomIds = kpiQuestionRepository
.findChatRoomIdsWithQuestionsFromList(createdChatRoomIds.mapNotNull { it.id })
.toSet()
val (questionUsedRooms, questionNotUsedRooms) = createdChatRooms.partition {
it.id in questionUsedChatRoomIds
}
// 각 그룹별 메트릭 계산 (평균 메시지, 3턴 비율, CRR)
val usedMetrics = calculateChatMetrics(questionUsedRooms)
val notUsedMetrics = calculateChatMetrics(questionNotUsedRooms)
// DailyKpi에 저장
}새로운 헬퍼 메서드:
private fun calculateChatMetrics(chatRooms: List<ChatRoom>): ChatMetrics {
// 평균 메시지 수
// 3턴 이상 대화 비율
// 24시간 내 재방문률 (CRR)
return ChatMetrics(avgMessageCount, threeTurnRate, chatReturnRate)
}KpiQuestionRepository.kt - 새로운 쿼리 메서드 추가
@Query("""
SELECT DISTINCT crq.chatRoom.id
FROM ChatRoomQuestion crq
WHERE crq.chatRoom.id IN :chatRoomIds
AND crq.isInitial = false
""")
fun findChatRoomIdsWithQuestionsFromList(
@Param("chatRoomIds") chatRoomIds: List<Long>
): List<Long>QuestionJpaRepository.kt - 질문 통계 쿼리 추가
@Query("""
SELECT q.id, q.content, q.category, COUNT(p) as selectionCount
FROM Profile p
JOIN p.representativeQuestion q
WHERE q IS NOT NULL
GROUP BY q.id, q.content, q.category
ORDER BY COUNT(p) DESC
""")
fun findTopSelectedQuestions(pageable: Pageable): List<Array<Any>>
@Query("""
SELECT q.category, COUNT(p) as count
FROM Profile p
JOIN p.representativeQuestion q
WHERE q IS NOT NULL
GROUP BY q.category
ORDER BY COUNT(p) DESC
""")
fun findQuestionCategoryStats(): List<Array<Any>>KpiController.kt
@GetMapping("/question-insights")
@ResponseBody
@Operation(summary = "질문 콘텐츠 인사이트 조회")
fun getQuestionInsights(): ResponseEntity<Map<String, Any>> {
val insights = kpiService.getQuestionInsights()
return ResponseEntity.ok(insights)
}KpiService.kt
fun getQuestionInsights(): Map<String, Any> {
val topQuestions = questionRepository.findTopSelectedQuestions(PageRequest.of(0, 10))
.map { row -> mapOf(
"questionId" to row[0],
"content" to row[1],
"category" to row[2],
"selectionCount" to row[3]
)}
val categoryStats = questionRepository.findQuestionCategoryStats()
.map { row -> mapOf(
"category" to (row[0] as QuestionCategory).name,
"count" to row[1]
)}
return mapOf(
"topQuestions" to topQuestions,
"categoryStats" to categoryStats
)
}KpiSummaryResponse.kt / DailyKpiResponse.kt
// 질문 KPI (합계 및 평균)
val questionClickSum: Int,
val questionUsedChatroomsSum: Int,
val questionUsedAvgMessageCountAvg: BigDecimal,
val questionNotUsedAvgMessageCountAvg: BigDecimal,
val questionUsedThreeTurnRateAvg: BigDecimal,
val questionNotUsedThreeTurnRateAvg: BigDecimal,
val questionUsedChatReturnRateAvg: BigDecimal,
val questionNotUsedChatReturnRateAvg: BigDecimal,변경 전 → 변경 후:
questionUsedChatrooms→questionUsedChatroomsSumcodeRequestSum→codeUnlockRequestSumcodeApprovedSum→codeUnlockApprovedSumcodeApprovalRate→codeUnlockApprovalRateavgChatDuration→avgChatDurationDays
평균 메시지 수 바 차트 ID 추가:
<div class="comparison-bar" id="avgMsgBar" style="width: 42%"></div>
<div class="comparison-value" id="avgMsgValue">4.2</div>
<div class="comparison-bar question-used" id="questionUsedMsgBar" style="width: 63%"></div>
<div class="comparison-value" id="questionUsedMsgValue">6.3</div>
<div class="comparison-bar question-not-used" id="questionNotUsedMsgBar" style="width: 31%"></div>
<div class="comparison-value" id="questionNotUsedMsgValue">3.1</div>변경 전 → 변경 후:
chatroomChart→chatroomActivityChartchatRateChart→chatConversionChartquestionChart→questionClickChartcodeUnlockChart→codeReleaseChart
채팅 퍼널 데이터 업데이트:
// 3-2. 채팅 퍼널 데이터
const activeChatrooms = kpiData.activeChatroomsSum;
const firstMessageCount = Math.round(activeChatrooms * kpiData.firstMessageRateAvg / 100);
const threeTurnCount = Math.round(activeChatrooms * kpiData.threeTurnRateAvg / 100);
const revisitCount = Math.round(activeChatrooms * kpiData.chatReturnRateAvg / 100);
document.getElementById('funnelActiveChatrooms').textContent = activeChatrooms.toLocaleString();
document.getElementById('funnelFirstMessage').textContent = firstMessageCount.toLocaleString();
document.getElementById('funnelFirstMessagePercent').textContent = kpiData.firstMessageRateAvg.toFixed(0) + '%';
document.getElementById('funnelThreeTurn').textContent = threeTurnCount.toLocaleString();
document.getElementById('funnelThreeTurnPercent').textContent = kpiData.threeTurnRateAvg.toFixed(0) + '%';
document.getElementById('funnelRevisit').textContent = revisitCount.toLocaleString();
document.getElementById('funnelRevisitPercent').textContent = kpiData.chatReturnRateAvg.toFixed(0) + '%';평균 메시지 수 바 차트:
// 3-5. 평균 메시지 수 바 업데이트
const avgMsg = kpiData.avgMessageCountAvg;
const questionUsedMsg = kpiData.questionUsedAvgMessageCountAvg;
const questionNotUsedMsg = kpiData.questionNotUsedAvgMessageCountAvg;
const maxMsg = Math.max(avgMsg, questionUsedMsg, questionNotUsedMsg, 1);
document.getElementById('avgMsgValue').textContent = avgMsg.toFixed(1);
document.getElementById('avgMsgBar').style.width = ((avgMsg / maxMsg) * 100) + '%';
document.getElementById('questionUsedMsgValue').textContent = questionUsedMsg.toFixed(1);
document.getElementById('questionUsedMsgBar').style.width = ((questionUsedMsg / maxMsg) * 100) + '%';
document.getElementById('questionNotUsedMsgValue').textContent = questionNotUsedMsg.toFixed(1);
document.getElementById('questionNotUsedMsgBar').style.width = ((questionNotUsedMsg / maxMsg) * 100) + '%';질문 사용 여부별 대화 성과 비교:
// 4-3. 질문 사용 여부별 대화 성과 비교
document.getElementById('questionUsedAvgMsg').textContent = kpiData.questionUsedAvgMessageCountAvg.toFixed(1);
document.getElementById('questionUsedThreeTurn').textContent = kpiData.questionUsedThreeTurnRateAvg.toFixed(0) + '%';
document.getElementById('questionUsedCRR').textContent = kpiData.questionUsedChatReturnRateAvg.toFixed(0) + '%';
document.getElementById('questionNotUsedAvgMsg').textContent = kpiData.questionNotUsedAvgMessageCountAvg.toFixed(1);
document.getElementById('questionNotUsedThreeTurn').textContent = kpiData.questionNotUsedThreeTurnRateAvg.toFixed(0) + '%';
document.getElementById('questionNotUsedCRR').textContent = kpiData.questionNotUsedChatReturnRateAvg.toFixed(0) + '%';KPI 테이블 렌더링:
function updateKpiTable() {
if (!kpiData || !kpiData.dailyKpis) return;
const tbody = document.getElementById('kpiTableBody');
tbody.innerHTML = '';
kpiData.dailyKpis.forEach(daily => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="date-cell">${formatDate(daily.targetDate)}</td>
<td>${daily.signalSentCount}</td>
<td>${daily.signalAcceptedCount}</td>
<td>${daily.openChatroomsCount}</td>
<td>${daily.activeChatroomsCount}</td>
<td>${daily.firstMessageRate.toFixed(1)}%</td>
<td>${daily.threeTurnRate.toFixed(1)}%</td>
<td>${daily.chatReturnRate.toFixed(1)}%</td>
<td>${daily.avgMessageCount.toFixed(1)}</td>
<td>${daily.questionClickCount}</td>
<td>${daily.codeUnlockRequestCount}</td>
<td>${daily.codeUnlockApprovedCount}</td>
<td>${daily.closedChatroomsCount}</td>
`;
tbody.appendChild(row);
});
}질문 콘텐츠 인사이트:
async function loadQuestionInsights() {
const response = await fetch('/v1/admin/kpi/question-insights');
const data = await response.json();
// TOP 10 질문 테이블
const tbody = document.getElementById('questionRankTableBody');
tbody.innerHTML = '';
data.topQuestions.forEach((q, index) => {
const rank = index + 1;
const badgeClass = rank === 1 ? 'top1' : rank === 2 ? 'top2' : rank === 3 ? 'top3' : 'other';
const row = document.createElement('tr');
row.innerHTML = `
<td><span class="rank-badge ${badgeClass}">${rank}</span></td>
<td>${q.content}</td>
<td class="selection-count" style="text-align: right;">${q.selectionCount}</td>
`;
tbody.appendChild(row);
});
// 카테고리 분포 파이 차트
updateOrCreateChart('questionCategoryChart', {
type: 'doughnut',
data: {
labels: data.categoryStats.map(c => c.category),
datasets: [{
data: data.categoryStats.map(c => c.count),
backgroundColor: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#43e97b', '#fa7c91', '#ffc107', '#28a745']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } }
}
});
}잘못된 로직 (수정 전):
// 해당 날짜에 생성된 질문을 가진 채팅방 조회
val questionUsedChatRoomIds = kpiQuestionRepository
.findDistinctChatRoomIdsByCreatedAtBetweenExcludingInitial(utcStart, utcEnd)
.toSet()시나리오 예시:
- 📅 1월 1일: 채팅방 A 생성
- 📅 1월 2일: 채팅방 A에 질문 추가
- ❌ 결과: 1월 1일 KPI에서 채팅방 A가 "질문 미사용"으로 잘못 분류됨
올바른 로직 (수정 후):
// 1. 해당 날짜에 생성된 채팅방 ID 목록
val createdChatRoomIds = createdChatRooms.mapNotNull { it.id }
// 2. 이 채팅방들이 (언제든) 질문을 사용했는지 확인
val questionUsedChatRoomIds = if (createdChatRoomIds.isNotEmpty()) {
kpiQuestionRepository
.findChatRoomIdsWithQuestionsFromList(createdChatRoomIds)
.toSet()
} else {
emptySet<Long>()
}새로운 Repository 메서드:
/**
* 특정 채팅방 목록 중 초기 질문이 아닌 질문을 사용한 채팅방 ID 목록
* (날짜 무관, 해당 채팅방에 질문이 있는지만 확인)
*/
@Query("""
SELECT DISTINCT crq.chatRoom.id
FROM ChatRoomQuestion crq
WHERE crq.chatRoom.id IN :chatRoomIds
AND crq.isInitial = false
""")
fun findChatRoomIdsWithQuestionsFromList(
@Param("chatRoomIds") chatRoomIds: List<Long>
): List<Long>✅ 정확한 분류: 채팅방 생성일 기준으로, 해당 채팅방이 이후 언제든 질문을 사용했는지 올바르게 판단 ✅ 신뢰성 향상: 질문 사용/미사용 채팅방의 성과 비교 데이터 정확도 향상 ✅ 디버깅 개선: 로그에 분류 결과 카운트 추가
질문 KPI: 사용 채팅방=50, 클릭 수=120 |
비교 메트릭 - 전체=100, 질문 사용=50, 미사용=50 |
사용 평균메시지=6.3, 미사용 평균메시지=3.1
백엔드 (9개 파일):
src/main/kotlin/codel/kpi/domain/DailyKpi.kt
src/main/kotlin/codel/kpi/business/KpiBatchService.kt
src/main/kotlin/codel/kpi/business/KpiService.kt
src/main/kotlin/codel/kpi/infrastructure/KpiQuestionRepository.kt
src/main/kotlin/codel/kpi/presentation/KpiController.kt
src/main/kotlin/codel/kpi/presentation/response/DailyKpiResponse.kt
src/main/kotlin/codel/kpi/presentation/response/KpiSummaryResponse.kt
src/main/kotlin/codel/question/infrastructure/QuestionJpaRepository.kt
src/main/resources/db/migration/V19__add_question_comparison_fields_to_daily_kpi.sql
프론트엔드 (1개 파일):
src/main/resources/templates/kpi-dashboard.html
# 1차 커밋: 12가지 시각화 문제 수정
7ce60a8 [feat] Fix all KPI dashboard visualization and data display issues
# 2차 커밋: 질문 KPI 수집 로직 버그 수정
382448f [fix] Fix question KPI collection logic error✅ Build Successful - 모든 변경사항 컴파일 성공
✅ No Breaking Changes - 기존 기능 영향 없음
URL: http://localhost:8080/v1/admin/kpi
📅 기간 선택:
- 빠른 선택: 오늘, 최근 7일, 최근 30일
- 사용자 지정 기간 선택 가능
📊 실시간 데이터 시각화:
- 시그널 KPI (보낸 수, 수락 수, 수락률, 날짜별 추이)
- 채팅 KPI (열린/활성 채팅방, FMR, 3턴 비율, CRR, 퍼널 분석)
- 질문추천 KPI (클릭 수, 사용 채팅방 수, 성과 비교)
- 코드해제 KPI (요청/승인 수, 승인률)
- 종료 채팅방 KPI (종료 수, 평균 유지 기간)
- 질문 콘텐츠 인사이트 (TOP 10, 카테고리 분포)
📋 KPI 테이블:
- 날짜별 상세 데이터 확인
- 모든 메트릭 한눈에 비교
# 특정 날짜 KPI 수동 집계
POST /v1/admin/kpi/aggregate?date=2025-01-01스케줄러 설정:
- 일일 자동 집계: 매일 새벽 1시 (한국 시간)
- 앱 시작 시: 최근 7일치 자동 집계
- ✅ 모든 더미 데이터 제거
- ✅ 실시간 API 데이터 사용
- ✅ 질문 사용 분류 로직 버그 수정
- ✅ 모든 차트 정상 렌더링
- ✅ 동적 바 차트로 비교 시각화 강화
- ✅ 질문 인사이트 기능 추가
- ✅ ID 명명 규칙 통일
- ✅ 재사용 가능한 헬퍼 함수 추가
- ✅ 로깅 강화로 디버깅 용이성 향상
- ✅ 질문 비교 메트릭 필드 추가로 향후 분석 가능
- ✅ 모듈화된 차트 생성 함수
- ✅ 질문 통계 API로 다양한 활용 가능
- V19 마이그레이션 실행 필수
- 기존 데이터는 기본값(0.00)으로 초기화
- 스케줄러 재실행으로 데이터 재집계 필요
- 질문 인사이트는 전체 프로필 스캔
- 대량 데이터 환경에서는 캐싱 고려 필요
- 마이그레이션 실행 확인
- 스케줄러 수동 실행 또는 앱 재시작으로 데이터 생성
- 대시보드 접속하여 모든 차트/데이터 정상 표시 확인
- 기간 필터 변경하여 동적 업데이트 확인
문서 작성일: 2025-12-30 작성자: Claude Sonnet 4.5 (Claude Code) 버전: 1.0