feat(mentor): 코호트(기수) 관리 + 기수 분석 대시보드
All checks were successful
CI / backend-test (push) Successful in 1m13s
CI / frontend-build (push) Successful in 40s
CI / backend-dep-scan (push) Successful in 29s

멘토 화면의 '분석·기수관리'를 개인 카드 나열에서 '묶어서 본 그림'으로 끌어올림.

기수(코호트):
- Flyway V2 마이그레이션으로 student_profile.cohort 추가(기존 학생 '2026' 백필)
- 가입 시 현재 기수(app.current-cohort, .env로 매년 변경) 자동 배정
- 명단에 기수 컬럼 + 배지 클릭으로 변경(POST /mentor/students/{id}/cohort)
- CSV 내보내기·StudentProfileResponse에 기수 포함

분석 대시보드(GET /mentor/analytics?cohort=):
- MentorAnalyticsService: 코호트별 KPI(진도·퀴즈·코딩·리뷰대기),
  학생별 마지막 활동 계산(4개 테이블 타임스탬프 max), 관심필요(7일+ 미활동),
  어려운 지점(평균 낮은 퀴즈·통과율 낮은 코딩 top5)
- MentorAnalytics 컴포넌트: 기수 선택·KPI 카드·관심필요·핫스팟, 슬러그→제목 해석

테스트: 백엔드 47→51(분석 4), 프론트 27→30(분석 3).
운영 검증: V2 빈DB→운영 무손실 적용(3명 백필), 분석 API·대시보드 브라우저 확인, 스모크 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-20 13:29:49 +09:00
parent 9045303a7a
commit bc2807db79
16 changed files with 773 additions and 6 deletions

View File

@ -63,6 +63,14 @@ public class StudentProfile {
@Column(nullable = false, length = 60)
private String major;
/**
* 기수(코호트) : "2026". 같은 시기에 들어온 수습생 묶음.
* 학습 포인트: 멘토가 "기수 단위" 진도·분석을 있게 하는 축이다. 해마다 기수가
* 들어와도 같은 플랫폼에서 기수별로 나눠 관리한다. 값은 가입 앱이 현재 기수로 채운다.
*/
@Column(nullable = false, length = 20)
private String cohort;
/** 비상 연락처 — 이름 / 관계 / 전화번호 */
@Column(nullable = false, length = 30)
private String emergencyName;
@ -88,7 +96,7 @@ public class StudentProfile {
public StudentProfile(User user, String companyEmailLocal, LocalDate birthDate, String phone,
String address, String school, String major,
String emergencyName, String emergencyRelation, String emergencyPhone,
Instant privacyConsentAt) {
Instant privacyConsentAt, String cohort) {
this.user = user;
this.companyEmailLocal = companyEmailLocal;
this.birthDate = birthDate;
@ -100,6 +108,12 @@ public class StudentProfile {
this.emergencyRelation = emergencyRelation;
this.emergencyPhone = emergencyPhone;
this.privacyConsentAt = privacyConsentAt;
this.cohort = cohort;
}
/** 멘토가 이 학생의 기수를 바꾼다(예: 잘못 배정됐거나 재편성). */
public void changeCohort(String cohort) {
this.cohort = cohort;
}
/**
@ -161,6 +175,10 @@ public class StudentProfile {
return major;
}
public String getCohort() {
return cohort;
}
public String getEmergencyName() {
return emergencyName;
}

View File

@ -19,6 +19,9 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
/** 특정 사용자의 프로필. */
Optional<StudentProfile> findByUser(User user);
/** 학생 user_id로 프로필 조회 (멘토의 기수 변경 등). user.id 중첩 프로퍼티로 쿼리 생성. */
Optional<StudentProfile> findByUserId(Long userId);
/**
* 전체 프로필을 User까지 함께 가져온다 (멘토 명단·CSV용).
*

View File

@ -28,6 +28,10 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final TelegramNotifier telegramNotifier;
/** 가입하는 학생이 편입될 현재 기수(코호트). 운영에선 .env의 CURRENT_COHORT로 매년 바꾼다. */
@org.springframework.beans.factory.annotation.Value("${app.current-cohort:2026}")
private String currentCohort;
public AuthService(UserRepository userRepository,
StudentProfileRepository studentProfileRepository,
PasswordEncoder passwordEncoder,
@ -104,7 +108,7 @@ public class AuthService {
user, req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
req.school(), req.major(),
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
java.time.Instant.now()));
java.time.Instant.now(), currentCohort));
// 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다).
telegramNotifier.send("🎓 새 수습생 가입\n"

View File

@ -0,0 +1,218 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.CourseProgress;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* 파일이 하는 :
* 멘토용 "코호트(기수) 분석" 학생 개개인 카드로는 보이는 "묶어서 본 그림" 만든다.
* - 코호트별 KPI(진도·퀴즈·코딩·리뷰대기), 학생별 요약 + 마지막 활동/관심필요,
* - "어려운 지점"(평균 점수가 낮은 퀴즈·통과율이 낮은 코딩 문제) top 5.
*
* 학습 포인트 "데이터의 주인" 지킨다.
* 서버는 자기가 저장한 (퀴즈 점수·코딩 통과·활동 시각) 집계한다. "코스 총 몇 개"
* 프론트 카탈로그의 지식이라 여기서 % 만들지 않고 coursesDone(원자료) 준다 화면이 계산.
*
* 학습 포인트 지금 규모(학생 4명)에선 학생마다 조회(N+1)해도 충분히 빠르다.
* 수백 명이 되면 집계 쿼리(GROUP BY) 바꾼다. "규모가 설계를 정한다."
*/
@Service
public class MentorAnalyticsService {
/** 이 일수를 넘게 아무 활동이 없으면 '관심 필요'로 본다. */
public static final int STALE_DAYS = 7;
private final StudentProfileRepository studentProfileRepository;
private final QuizResultRepository quizResultRepository;
private final CodingSubmissionRepository codingSubmissionRepository;
private final CodingProblemRepository codingProblemRepository;
private final CourseProgressRepository courseProgressRepository;
private final ProgressRepository progressRepository;
private final ChecklistItemRepository checklistItemRepository;
private final SubmissionRepository submissionRepository;
private final Clock clock;
public MentorAnalyticsService(StudentProfileRepository studentProfileRepository,
QuizResultRepository quizResultRepository,
CodingSubmissionRepository codingSubmissionRepository,
CodingProblemRepository codingProblemRepository,
CourseProgressRepository courseProgressRepository,
ProgressRepository progressRepository,
ChecklistItemRepository checklistItemRepository,
SubmissionRepository submissionRepository,
Clock clock) {
this.studentProfileRepository = studentProfileRepository;
this.quizResultRepository = quizResultRepository;
this.codingSubmissionRepository = codingSubmissionRepository;
this.codingProblemRepository = codingProblemRepository;
this.courseProgressRepository = courseProgressRepository;
this.progressRepository = progressRepository;
this.checklistItemRepository = checklistItemRepository;
this.submissionRepository = submissionRepository;
this.clock = clock;
}
public record CohortCount(String cohort, int count) {
}
/** 학생 한 명의 분석 요약 한 줄. lastActiveAt이 null이면 아직 아무 활동도 없다. */
public record StudentRow(Long userId, String name, String track, String cohort,
int coursesDone, int quizAttempted, int quizAvgPct,
int codingPassed, int codingTotal,
int checklistDone, int checklistTotal,
String lastActiveAt, Long daysSinceActive) {
}
public record QuizHotspot(String courseSlug, int avgScorePct, int attempts) {
}
public record CodingHotspot(Long problemId, String title, int passRatePct, int attempts) {
}
public record Analytics(String cohort, int studentCount, int pendingReviews,
List<CohortCount> cohorts, List<StudentRow> students,
List<QuizHotspot> hardestQuizzes, List<CodingHotspot> hardestCoding) {
}
@Transactional(readOnly = true)
public Analytics analytics(String cohortFilter) {
List<StudentProfile> all = studentProfileRepository.findAllWithUser();
// 코호트 목록 + 인원수 (필터와 무관하게 전체 기준 화면의 기수 선택지가 된다)
Map<String, Integer> counts = new TreeMap<>();
for (StudentProfile p : all) {
counts.merge(p.getCohort(), 1, Integer::sum);
}
List<CohortCount> cohorts = counts.entrySet().stream()
.map(e -> new CohortCount(e.getKey(), e.getValue()))
.toList();
boolean allCohorts = cohortFilter == null || cohortFilter.isBlank() || "all".equals(cohortFilter);
List<StudentProfile> scoped = allCohorts ? all
: all.stream().filter(p -> cohortFilter.equals(p.getCohort())).toList();
int codingTotal = (int) codingProblemRepository.count();
int checklistTotal = (int) checklistItemRepository.count();
Instant now = clock.instant();
// 핫스팟 누적기: 코스별 [점수합, 응시자수] / 문제별 [통과수, 시도자수]
Map<String, int[]> quizAgg = new HashMap<>();
Map<Long, int[]> codeAgg = new HashMap<>();
Map<Long, String> codeTitle = new HashMap<>();
int pendingReviews = 0;
List<StudentRow> rows = new ArrayList<>();
for (StudentProfile p : scoped) {
User u = p.getUser();
Instant last = null;
// 퀴즈 코스별 최고 점수(%)
Map<String, Integer> bestPct = new HashMap<>();
for (QuizResult qr : quizResultRepository.findByUserOrderBySubmittedAtDesc(u)) {
int pct = qr.getTotal() > 0 ? qr.getScore() * 100 / qr.getTotal() : 0;
bestPct.merge(qr.getCourseSlug(), pct, Math::max);
last = later(last, qr.getSubmittedAt());
}
int quizAttempted = bestPct.size();
int quizAvgPct = quizAttempted == 0 ? 0
: (int) Math.round(bestPct.values().stream().mapToInt(Integer::intValue).average().orElse(0));
for (Map.Entry<String, Integer> e : bestPct.entrySet()) {
int[] agg = quizAgg.computeIfAbsent(e.getKey(), k -> new int[2]);
agg[0] += e.getValue();
agg[1] += 1;
}
// 코딩 통과 + 문제별 통과율 누적
int codingPassed = 0;
for (CodingSubmission cs : codingSubmissionRepository.findByUser(u)) {
boolean passed = "PASSED".equals(cs.getStatus());
if (passed) {
codingPassed++;
}
Long pid = cs.getProblem().getId();
int[] agg = codeAgg.computeIfAbsent(pid, k -> new int[2]);
if (passed) {
agg[0] += 1;
}
agg[1] += 1;
codeTitle.putIfAbsent(pid, cs.getProblem().getTitle());
last = later(last, cs.getSubmittedAt());
}
// 코스 진도 (완료한 slug ) 코스 수는 프론트가 카탈로그로 안다
List<CourseProgress> courses = courseProgressRepository.findByUser(u);
for (CourseProgress cp : courses) {
last = later(last, cp.getCompletedAt());
}
// 체크리스트 완료
int checklistDone = progressRepository.findByUser(u).size();
// 과제 리뷰 대기(SUBMITTED) 집계 + 활동 시각
for (Submission s : submissionRepository.findByUserOrderBySubmittedAtDesc(u)) {
if ("SUBMITTED".equals(s.getStatus())) {
pendingReviews++;
}
last = later(last, s.getSubmittedAt());
}
Long daysSince = last == null ? null : Duration.between(last, now).toDays();
rows.add(new StudentRow(u.getId(), u.getName(), u.getTrack(), p.getCohort(),
courses.size(), quizAttempted, quizAvgPct, codingPassed, codingTotal,
checklistDone, checklistTotal,
last == null ? null : last.toString(), daysSince));
}
// 어려운 지점 = 평균이 낮은 top 5 (응시/시도가 있는 것만)
List<QuizHotspot> hardestQuizzes = quizAgg.entrySet().stream()
.map(e -> new QuizHotspot(e.getKey(), e.getValue()[0] / e.getValue()[1], e.getValue()[1]))
.sorted(Comparator.comparingInt(QuizHotspot::avgScorePct))
.limit(5)
.toList();
List<CodingHotspot> hardestCoding = codeAgg.entrySet().stream()
.map(e -> new CodingHotspot(e.getKey(), codeTitle.get(e.getKey()),
e.getValue()[0] * 100 / e.getValue()[1], e.getValue()[1]))
.sorted(Comparator.comparingInt(CodingHotspot::passRatePct))
.limit(5)
.toList();
String label = allCohorts ? "전체" : cohortFilter;
return new Analytics(label, scoped.size(), pendingReviews, cohorts, rows,
hardestQuizzes, hardestCoding);
}
/** 두 시각 중 나중 것(둘 중 하나가 null이면 나머지). */
private static Instant later(Instant a, Instant b) {
if (a == null) {
return b;
}
if (b == null) {
return a;
}
return a.isAfter(b) ? a : b;
}
}

View File

@ -106,12 +106,13 @@ public class StudentProfileService {
StringBuilder sb = new StringBuilder(""); // BOM
// 헤더
sb.append("이름,회사이메일,트랙,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
sb.append("이름,회사이메일,트랙,기수,생년월일,연락처,주소,학교,전공,비상연락-이름,비상연락-관계,비상연락-전화,개인정보동의일,가입일\n");
for (StudentProfile p : allProfiles()) {
sb.append(csv(p.getUser().getName())).append(',')
.append(csv(p.fullEmail())).append(',')
.append(csv("DESIGN".equals(p.getUser().getTrack()) ? "디자인" : "개발")).append(',')
.append(csv(p.getCohort())).append(',')
.append(csv(p.getBirthDate().toString())).append(',')
.append(csv(p.getPhone())).append(',')
.append(csv(p.getAddress())).append(',')
@ -151,4 +152,13 @@ public class StudentProfileService {
}
return "\"" + v.replace("\"", "\"\"") + "\"";
}
/** 멘토가 학생의 기수(코호트)를 바꾼다. */
@Transactional
public void changeCohort(Long userId, String cohort) {
StudentProfile p = studentProfileRepository.findByUserId(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "학생을 찾을 수 없습니다."));
p.changeCohort(cohort.trim());
// @Transactional 안에서 managed 엔티티를 바꾸면 dirty checking으로 UPDATE가 나간다.
}
}

View File

@ -6,6 +6,7 @@ import dev.awesomedev.mirim.domain.Submission;
import dev.awesomedev.mirim.repository.UserRepository;
import dev.awesomedev.mirim.service.AuditService;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.MentorAnalyticsService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.ProgressService;
@ -15,6 +16,7 @@ import dev.awesomedev.mirim.service.StudentPurgeService;
import dev.awesomedev.mirim.service.SubmissionService;
import dev.awesomedev.mirim.web.dto.AuditLogResponse;
import dev.awesomedev.mirim.web.dto.ChecklistProgressOverviewResponse;
import dev.awesomedev.mirim.web.dto.CohortRequest;
import dev.awesomedev.mirim.web.dto.FeedbackRequest;
import dev.awesomedev.mirim.web.dto.MentorResetPasswordRequest;
import dev.awesomedev.mirim.web.dto.MentorSubmissionResponse;
@ -57,6 +59,7 @@ public class MentorController {
private final StudentPurgeService studentPurgeService;
private final UserRepository userRepository;
private final AuditService auditService;
private final MentorAnalyticsService mentorAnalyticsService;
public MentorController(SubmissionService submissionService,
CourseProgressService courseProgressService,
@ -67,7 +70,8 @@ public class MentorController {
StudentProfileService studentProfileService,
StudentPurgeService studentPurgeService,
UserRepository userRepository,
AuditService auditService) {
AuditService auditService,
MentorAnalyticsService mentorAnalyticsService) {
this.submissionService = submissionService;
this.courseProgressService = courseProgressService;
this.progressService = progressService;
@ -78,6 +82,23 @@ public class MentorController {
this.studentPurgeService = studentPurgeService;
this.userRepository = userRepository;
this.auditService = auditService;
this.mentorAnalyticsService = mentorAnalyticsService;
}
/**
* GET /api/mentor/analytics?cohort=2026 코호트(기수) 단위 분석.
* cohort 없으면 전체. 코호트 목록·KPI·학생별 요약·어려운 지점을 번에 준다.
*/
@GetMapping("/analytics")
public MentorAnalyticsService.Analytics analytics(
@RequestParam(name = "cohort", required = false) String cohort) {
return mentorAnalyticsService.analytics(cohort);
}
/** POST /api/mentor/students/{id}/cohort — 학생의 기수를 바꾼다. */
@PostMapping("/students/{id}/cohort")
public void changeCohort(@PathVariable Long id, @Valid @RequestBody CohortRequest request) {
studentProfileService.changeCohort(id, request.cohort());
}
/**

View File

@ -0,0 +1,15 @@
package dev.awesomedev.mirim.web.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 파일이 하는 : 멘토가 학생의 기수(코호트) 바꿀 보내는 본문 {cohort}.
*/
public record CohortRequest(
@NotBlank(message = "기수는 필수입니다.")
@Size(max = 20, message = "기수는 20자 이하입니다.")
String cohort
) {
}

View File

@ -15,6 +15,7 @@ public record StudentProfileResponse(
String name,
String username,
String track,
String cohort,
String companyEmail,
LocalDate birthDate,
String phone,
@ -31,6 +32,7 @@ public record StudentProfileResponse(
p.getUser().getName(),
p.getUser().getUsername(),
p.getUser().getTrack(),
p.getCohort(),
p.fullEmail(),
p.getBirthDate(),
p.getPhone(),

View File

@ -32,6 +32,11 @@ spring:
baseline-on-migrate: true
baseline-version: 1
# 앱 도메인 설정
app:
# 지금 가입하는 학생이 편입될 기수(코호트). 새 기수를 받을 땐 운영 .env에서 이 값만 바꾼다.
current-cohort: ${CURRENT_COHORT:2026}
server:
port: 8080
error:

View File

@ -0,0 +1,10 @@
-- V2: 수습생 프로필에 코호트(기수) 컬럼 추가.
--
-- 학습 포인트: 이게 Flyway를 도입한 진짜 이유다 — 운영 DB의 데이터를 지키면서
-- 스키마를 "버전 관리하며" 바꾼다. V1(baseline) 이후의 첫 실제 마이그레이션.
-- 기존 학생들은 DEFAULT로 '2026' 기수에 자동 편입된다(백필).
ALTER TABLE student_profile ADD COLUMN cohort varchar(20) NOT NULL DEFAULT '2026';
-- 백필이 끝났으니 DB 기본값은 떼어 낸다 — 이후 값은 앱(Java)이 가입 시 항상 넣어 주므로,
-- "값의 출처는 한 곳(앱)"으로 두는 게 깔끔하다. (엔티티는 nullable=false varchar(20)와 일치)
ALTER TABLE student_profile ALTER COLUMN cohort DROP DEFAULT;

View File

@ -0,0 +1,180 @@
package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.CodingProblem;
import dev.awesomedev.mirim.domain.CodingSubmission;
import dev.awesomedev.mirim.domain.QuizResult;
import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.ChecklistItemRepository;
import dev.awesomedev.mirim.repository.CodingProblemRepository;
import dev.awesomedev.mirim.repository.CodingSubmissionRepository;
import dev.awesomedev.mirim.repository.CourseProgressRepository;
import dev.awesomedev.mirim.repository.ProgressRepository;
import dev.awesomedev.mirim.repository.QuizResultRepository;
import dev.awesomedev.mirim.repository.StudentProfileRepository;
import dev.awesomedev.mirim.repository.SubmissionRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 파일이 하는 : 코호트 분석의 집계 로직을 검증한다 코호트 필터, 퀴즈 평균,
* '마지막 활동/관심필요' 일수 계산, 어려운 지점(핫스팟) 정렬.
*
* 학습 포인트 시각(Clock) 고정해 "며칠 전 활동" 결정적으로 검증한다.
* 학습 포인트 Mockito 함정: when(...).thenReturn(X) X를 만드는 도중에 when(...)
* 부르면 "UnfinishedStubbing" 오류가 난다. 그래서 객체는 먼저 변수에 만들어 두고 넘긴다.
*/
class MentorAnalyticsServiceTest {
private static final Instant NOW = Instant.parse("2026-09-10T09:00:00Z");
private final Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
private final StudentProfileRepository profileRepo = mock(StudentProfileRepository.class);
private final QuizResultRepository quizRepo = mock(QuizResultRepository.class);
private final CodingSubmissionRepository codeRepo = mock(CodingSubmissionRepository.class);
private final CodingProblemRepository problemRepo = mock(CodingProblemRepository.class);
private final CourseProgressRepository courseRepo = mock(CourseProgressRepository.class);
private final ProgressRepository progressRepo = mock(ProgressRepository.class);
private final ChecklistItemRepository checklistRepo = mock(ChecklistItemRepository.class);
private final SubmissionRepository submissionRepo = mock(SubmissionRepository.class);
private final MentorAnalyticsService service = new MentorAnalyticsService(
profileRepo, quizRepo, codeRepo, problemRepo, courseRepo, progressRepo,
checklistRepo, submissionRepo, clock);
private User student(String name, String track) {
return new User(name, "해시", name, "STUDENT", track);
}
private StudentProfile profile(User u, String cohort) {
StudentProfile p = mock(StudentProfile.class);
when(p.getUser()).thenReturn(u);
when(p.getCohort()).thenReturn(cohort);
return p;
}
@Test
@DisplayName("코호트 목록과 인원수를 집계하고, 필터로 해당 기수만 남긴다")
void cohorts_and_filter() {
StudentProfile pa = profile(student("", "DEV"), "2025");
StudentProfile pb = profile(student("", "DEV"), "2026");
StudentProfile pc = profile(student("", "DESIGN"), "2026");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb, pc));
var all = service.analytics(null);
assertEquals(3, all.studentCount());
assertEquals(List.of(
new MentorAnalyticsService.CohortCount("2025", 1),
new MentorAnalyticsService.CohortCount("2026", 2)), all.cohorts());
var only2026 = service.analytics("2026");
assertEquals(2, only2026.studentCount());
assertTrue(only2026.students().stream().allMatch(s -> s.cohort().equals("2026")));
}
@Test
@DisplayName("퀴즈는 코스별 최고점의 평균(%)을 내고, 마지막 활동 일수를 계산한다")
void quiz_average_and_last_active() {
User a = student("", "DEV");
StudentProfile pa = profile(a, "2026");
// 같은 코스 2회 응시(최고점 채택: 5/5=100 vs 3/5=60 100), 다른 코스 1회(2/4=50)
QuizResult q1 = quiz("c1", 5, 5, NOW.minus(Duration.ofDays(3)));
QuizResult q2 = quiz("c1", 3, 5, NOW.minus(Duration.ofDays(10)));
QuizResult q3 = quiz("c2", 2, 4, NOW.minus(Duration.ofDays(5)));
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa));
when(quizRepo.findByUserOrderBySubmittedAtDesc(a)).thenReturn(List.of(q1, q2, q3));
var row = service.analytics(null).students().get(0);
assertEquals(2, row.quizAttempted());
assertEquals(75, row.quizAvgPct()); // (100 + 50) / 2
assertEquals(3L, row.daysSinceActive()); // 가장 최근 활동이 3일
}
@Test
@DisplayName("활동이 없으면 lastActive는 null, daysSinceActive도 null이다")
void no_activity() {
StudentProfile pa = profile(student("", "DEV"), "2026");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa));
var row = service.analytics(null).students().get(0);
assertNull(row.lastActiveAt());
assertNull(row.daysSinceActive());
assertEquals(0, row.quizAvgPct());
}
@Test
@DisplayName("어려운 코딩 문제는 통과율 낮은 순으로 정렬된다")
void coding_hotspots_sorted_by_pass_rate() {
User a = student("", "DEV");
User b = student("", "DEV");
StudentProfile pa = profile(a, "2026");
StudentProfile pb = profile(b, "2026");
CodingProblem easy = problem(10L, "쉬운문제");
CodingProblem hard = problem(20L, "어려운문제");
// easy: 통과(100%). hard: 명만 통과(50%)
CodingSubmission a1 = sub(easy, "PASSED");
CodingSubmission a2 = sub(hard, "PASSED");
CodingSubmission b1 = sub(easy, "PASSED");
CodingSubmission b2 = sub(hard, "FAILED");
emptyActivity();
when(profileRepo.findAllWithUser()).thenReturn(List.of(pa, pb));
when(codeRepo.findByUser(a)).thenReturn(List.of(a1, a2));
when(codeRepo.findByUser(b)).thenReturn(List.of(b1, b2));
var hot = service.analytics(null).hardestCoding();
assertEquals(2, hot.size());
assertEquals("어려운문제", hot.get(0).title()); // 통과율 낮은 먼저
assertEquals(50, hot.get(0).passRatePct());
assertEquals(100, hot.get(1).passRatePct());
}
// 헬퍼: 객체는 먼저 만들어 두고, when()에는 완성된 객체만 넘긴다
private void emptyActivity() {
when(problemRepo.count()).thenReturn(25L);
when(checklistRepo.count()).thenReturn(30L);
when(quizRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of());
when(codeRepo.findByUser(any())).thenReturn(List.of());
when(courseRepo.findByUser(any())).thenReturn(List.of());
when(progressRepo.findByUser(any())).thenReturn(List.of());
when(submissionRepo.findByUserOrderBySubmittedAtDesc(any())).thenReturn(List.of());
}
private QuizResult quiz(String slug, int score, int total, Instant at) {
QuizResult qr = mock(QuizResult.class);
when(qr.getCourseSlug()).thenReturn(slug);
when(qr.getScore()).thenReturn(score);
when(qr.getTotal()).thenReturn(total);
when(qr.getSubmittedAt()).thenReturn(at);
return qr;
}
private CodingProblem problem(Long id, String title) {
CodingProblem p = mock(CodingProblem.class);
when(p.getId()).thenReturn(id);
when(p.getTitle()).thenReturn(title);
return p;
}
private CodingSubmission sub(CodingProblem problem, String status) {
CodingSubmission cs = mock(CodingSubmission.class);
when(cs.getProblem()).thenReturn(problem);
when(cs.getStatus()).thenReturn(status);
when(cs.getSubmittedAt()).thenReturn(NOW.minus(Duration.ofDays(2)));
return cs;
}
}

View File

@ -45,7 +45,7 @@ class StudentProfileServiceTest {
ReflectionTestUtils.setField(student, "id", 3L);
profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27),
"010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW",
"차정순", "", "010-4316-6756", Instant.now());
"차정순", "", "010-4316-6756", Instant.now(), "2026");
}
private ProfileUpdateRequest request(String emailLocal, String address) {
@ -106,7 +106,7 @@ class StudentProfileServiceTest {
User attacker = new User("evil", "해시", "=HYPERLINK(\"http://evil/\")", "STUDENT", "DEV");
ReflectionTestUtils.setField(attacker, "id", 9L); // createdAt은 생성자가 이미 채움
StudentProfile evilProfile = new StudentProfile(attacker, "evil", LocalDate.of(2008, 1, 1),
"010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "", "010-1111-1111", Instant.now());
"010-0000-0000", "@SUM(A1)", "학교", "전공", "보호자", "", "010-1111-1111", Instant.now(), "2026");
when(studentProfileRepository.findAllWithUser()).thenReturn(List.of(evilProfile));
String csv = service.exportCsv();

View File

@ -8,6 +8,7 @@ import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.CodingService;
import dev.awesomedev.mirim.service.CourseProgressService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.service.MentorAnalyticsService;
import dev.awesomedev.mirim.service.ProgressService;
import dev.awesomedev.mirim.service.QuizService;
import dev.awesomedev.mirim.service.RateLimiterService;
@ -78,6 +79,7 @@ class SecurityContractTest {
@MockitoBean private QuizService quizService;
@MockitoBean private StudentProfileService studentProfileService;
@MockitoBean private StudentPurgeService studentPurgeService;
@MockitoBean private MentorAnalyticsService mentorAnalyticsService;
@MockitoBean private UserRepository userRepository;
/** 실제 로그인과 똑같이, 주어진 역할의 인증 정보를 담은 세션을 만든다. */

View File

@ -0,0 +1,192 @@
// : "() ".
// " "
// KPI(···), , (·).
//
// : " " % .
// ( ··) , % .
import { useEffect, useMemo, useState } from 'react';
import client from '../api/client';
import { ALL_COURSES } from '../courseCatalog';
import { LEVEL_COURSES } from '../levelCatalog';
const TOTAL_COURSES = ALL_COURSES.length + LEVEL_COURSES.length;
// slug , .
const SLUG_TITLE = new Map(
[...ALL_COURSES, ...LEVEL_COURSES].map((c) => [c.slug, c.title]),
);
function Kpi({ label, value, sub }) {
return (
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: 26, fontWeight: 800, color: 'var(--primary)' }}>{value}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{label}</div>
{sub && <div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>{sub}</div>}
</div>
);
}
function daysLabel(d) {
if (d == null) return '활동 없음';
if (d === 0) return '오늘';
return `${d}일 전`;
}
export default function MentorAnalytics() {
const [cohort, setCohort] = useState('all');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
const q = cohort === 'all' ? '' : `?cohort=${encodeURIComponent(cohort)}`;
client
.get(`/mentor/analytics${q}`)
.then((res) => {
if (!cancelled) {
setData(res.data);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [cohort]);
const kpi = useMemo(() => {
if (!data) return null;
const rows = data.students;
const n = rows.length;
const avgCoursePct = n
? Math.round((rows.reduce((s, x) => s + x.coursesDone, 0) / n / TOTAL_COURSES) * 100)
: 0;
const quizzed = rows.filter((x) => x.quizAttempted > 0);
const avgQuiz = quizzed.length
? Math.round(quizzed.reduce((s, x) => s + x.quizAvgPct, 0) / quizzed.length)
: 0;
const codingRate = n
? Math.round(
(rows.reduce((s, x) => s + (x.codingTotal ? x.codingPassed / x.codingTotal : 0), 0) / n) * 100,
)
: 0;
return { n, avgCoursePct, avgQuiz, quizzedCount: quizzed.length, codingRate };
}, [data]);
if (loading && !data) return <p className="empty">기수 분석을 불러오는 ...</p>;
if (!data) return null;
// : 7 .
const atRisk = data.students
.filter((x) => x.daysSinceActive == null || x.daysSinceActive >= 7)
.sort((a, b) => (b.daysSinceActive ?? 99999) - (a.daysSinceActive ?? 99999));
const totalHead = data.cohorts.reduce((s, c) => s + c.count, 0);
return (
<div style={{ marginBottom: 26 }}>
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
flexWrap: 'wrap', gap: 8, marginBottom: 12,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>📊 기수 분석</h2>
<select
className="input"
style={{ width: 'auto' }}
value={cohort}
onChange={(e) => setCohort(e.target.value)}
aria-label="기수 선택"
>
<option value="all">전체 ({totalHead})</option>
{data.cohorts.map((c) => (
<option key={c.cohort} value={c.cohort}>{c.cohort} ({c.count})</option>
))}
</select>
</div>
{/* KPI 카드 */}
<div className="grid-cards" style={{ marginBottom: 18 }}>
<Kpi label="학생" value={`${kpi.n}`} />
<Kpi label="평균 코스 진도" value={`${kpi.avgCoursePct}%`} sub={`${TOTAL_COURSES}강좌 기준`} />
<Kpi label="평균 퀴즈 점수" value={`${kpi.avgQuiz}%`} sub={`응시 ${kpi.quizzedCount}`} />
<Kpi label="코딩 통과율" value={`${kpi.codingRate}%`} />
<Kpi label="리뷰 대기" value={`${data.pendingReviews}`} />
</div>
{/* 관심 필요 학생 */}
<h3 className="section-title" style={{ fontSize: 15 }}>🔔 관심 필요 (7 넘게 활동 없음)</h3>
{atRisk.length === 0 ? (
<p className="empty">모두 최근에 활동했어요 👍</p>
) : (
<div className="card" style={{ padding: 0, overflowX: 'auto', marginBottom: 18 }}>
<table className="table table--nowrap" style={{ minWidth: 420 }}>
<thead>
<tr>
<th>학생</th>
<th>트랙</th>
<th style={{ textAlign: 'center' }}>마지막 활동</th>
<th style={{ textAlign: 'center' }}>완료 코스</th>
</tr>
</thead>
<tbody>
{atRisk.map((s) => (
<tr key={s.userId}>
<td style={{ fontWeight: 600 }}>{s.name}</td>
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
<td style={{ textAlign: 'center', color: s.daysSinceActive == null ? 'var(--rose)' : 'inherit' }}>
{daysLabel(s.daysSinceActive)}
</td>
<td style={{ textAlign: 'center', fontVariantNumeric: 'tabular-nums' }}>
{s.coursesDone} / {TOTAL_COURSES}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 어려운 지점 — 퀴즈 / 코딩 */}
<div className="grid-cards" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
<div className="card">
<h3 className="section-title" style={{ fontSize: 15, marginTop: 0 }}>🧩 어려운 퀴즈 (평균 낮은 )</h3>
{data.hardestQuizzes.length === 0 ? (
<p className="empty">아직 퀴즈 응시 기록이 없어요.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{data.hardestQuizzes.map((q) => (
<li key={q.courseSlug} style={{ marginBottom: 4 }}>
<span>{SLUG_TITLE.get(q.courseSlug) || q.courseSlug}</span>
{' — '}
<b style={{ color: q.avgScorePct < 60 ? 'var(--rose)' : 'var(--ink)' }}>{q.avgScorePct}%</b>
<span className="muted" style={{ fontSize: 12 }}> (응시 {q.attempts})</span>
</li>
))}
</ul>
)}
</div>
<div className="card">
<h3 className="section-title" style={{ fontSize: 15, marginTop: 0 }}>💻 어려운 코딩 (통과율 낮은 )</h3>
{data.hardestCoding.length === 0 ? (
<p className="empty">아직 코딩 제출 기록이 없어요.</p>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{data.hardestCoding.map((c) => (
<li key={c.problemId} style={{ marginBottom: 4 }}>
<span>{c.title}</span>
{' — '}
<b style={{ color: c.passRatePct < 60 ? 'var(--rose)' : 'var(--ink)' }}>{c.passRatePct}%</b>
<span className="muted" style={{ fontSize: 12 }}> (시도 {c.attempts})</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
// api .
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
import client from '../api/client'
import MentorAnalytics from './MentorAnalytics.jsx'
const SAMPLE = {
cohort: '전체',
studentCount: 2,
pendingReviews: 3,
cohorts: [{ cohort: '2026', count: 2 }],
students: [
{
userId: 1, name: '가영', track: 'DEV', cohort: '2026',
coursesDone: 10, quizAttempted: 2, quizAvgPct: 80,
codingPassed: 5, codingTotal: 10, checklistDone: 3, checklistTotal: 10,
lastActiveAt: '2026-09-08T00:00:00Z', daysSinceActive: 2,
},
{
userId: 2, name: '나윤', track: 'DEV', cohort: '2026',
coursesDone: 0, quizAttempted: 0, quizAvgPct: 0,
codingPassed: 0, codingTotal: 10, checklistDone: 0, checklistTotal: 10,
lastActiveAt: null, daysSinceActive: null,
},
],
hardestQuizzes: [{ courseSlug: 'lv1-loops', avgScorePct: 40, attempts: 2 }],
hardestCoding: [{ problemId: 1, title: '별 찍기', passRatePct: 25, attempts: 4 }],
}
describe('MentorAnalytics', () => {
beforeEach(() => {
client.get.mockReset()
client.get.mockResolvedValue({ data: SAMPLE })
})
it('KPI와 리뷰 대기 건수를 보여준다', async () => {
render(<MentorAnalytics />)
expect(await screen.findByText('2명')).toBeInTheDocument() //
expect(screen.getByText('3건')).toBeInTheDocument() //
})
it('활동 없는 학생을 "관심 필요"에 "활동 없음"으로 올린다', async () => {
render(<MentorAnalytics />)
await waitFor(() => expect(screen.getByText('나윤')).toBeInTheDocument())
expect(screen.getByText('활동 없음')).toBeInTheDocument()
})
it('어려운 퀴즈는 slug를 강좌 제목으로 바꿔 보여준다', async () => {
render(<MentorAnalytics />)
// lv1-loops ("...")
expect(await screen.findByText(/반복문/)).toBeInTheDocument()
expect(screen.getByText('별 찍기')).toBeInTheDocument() //
})
})

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import client from '../api/client';
import MentorCodingSection from '../components/MentorCodingSection';
import MentorAnalytics from '../components/MentorAnalytics';
import Card from '../components/Card';
import Badge from '../components/Badge';
import { COURSE_CATEGORIES, ALL_COURSES } from '../courseCatalog';
@ -215,6 +216,20 @@ function StudentRoster({ students, onChanged }) {
}
}
// () .
async function changeCohort(s) {
const next = window.prompt(`${s.name} 님의 기수를 입력하세요 (현재: ${s.cohort})`, s.cohort);
if (next === null) return;
const v = next.trim();
if (!v || v === s.cohort) return;
try {
await client.post(`/mentor/students/${s.userId}/cohort`, { cohort: v });
onChanged();
} catch {
window.alert('기수 변경 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}
}
return (
<Card style={{ padding: 0, overflowX: 'auto' }}>
<div style={{ padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
@ -232,6 +247,7 @@ function StudentRoster({ students, onChanged }) {
<th>이름</th>
<th>회사 이메일</th>
<th>트랙</th>
<th>기수</th>
<th>생년월일</th>
<th>연락처</th>
<th>학교</th>
@ -245,6 +261,17 @@ function StudentRoster({ students, onChanged }) {
<td style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{s.name}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.companyEmail}</td>
<td>{s.track === 'DESIGN' ? '디자인' : '개발'}</td>
<td>
{/* 기수 배지를 누르면 변경 — 클릭 가능함을 알 수 있게 button으로 감싼다 */}
<button
className="btn btn-ghost"
style={{ padding: '2px 6px' }}
onClick={() => changeCohort(s)}
title="기수 변경"
>
<span className="badge badge-primary">{s.cohort}</span>
</button>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.birthDate}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.phone}</td>
<td style={{ whiteSpace: 'nowrap' }}>{s.school}</td>
@ -470,6 +497,9 @@ export default function MentorPage() {
</p>
)}
{/* ── 기수(코호트) 분석 대시보드 — 묶어서 본 그림 ── */}
<MentorAnalytics />
{/* ── 수습생 명단 (인적사항) ── */}
<h2 className="section-title">수습생 명단</h2>
{roster.length === 0 ? (