728x90
날짜: 2025-12-10
작업 시간: 약 4시간
오늘 구현한 기능
- 통계 및 분석 기능 완성 - 주간/월간 통계, 신체 부위별 분석, 개인 기록 추적
- Stats API 5개 엔드포인트 구현 - RESTful API 설계 및 구현
- 1RM 계산 기능 - Brzycki 공식을 이용한 최대 중량 추정
- 통계 쿼리 최적화 - FETCH JOIN을 활용한 N+1 문제 해결
코드 작성 내역
1. Stats Response DTO 구현
파일: src/main/java/com/example/FitTracker/dto/response/stats/
WeeklyStatsResponse.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class WeeklyStatsResponse {
private LocalDate startDate;
private LocalDate endDate;
private Long totalWorkouts;
private Long totalSets;
private Integer totalMinutes;
private Double avgWorkoutsPerDay;
}
설명:
- 주간 운동 통계를 담는 DTO
- 총 운동 횟수, 세트 수, 시간과 일평균 계산
- Lombok의 @Builder 패턴 활용
MonthlyStatsResponse.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MonthlyStatsResponse {
private YearMonth yearMonth;
private Long totalWorkouts;
private Long totalSets;
private Integer totalMinutes;
private Double avgWorkoutsPerWeek;
private Integer totalDaysWorkedOut;
}
설명:
- 월간 통계 데이터 구조
- YearMonth 타입으로 년-월 표현
- 실제 운동한 날짜 수 추적
BodyPartStatsResponse.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BodyPartStatsResponse {
private String bodyPart;
private Long totalSets;
private Double percentage;
}
설명:
- 신체 부위별(가슴, 등, 다리 등) 운동량 분석
- 전체 대비 비율 계산
PersonalRecordResponse.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PersonalRecordResponse {
private Long exerciseTypeId;
private String exerciseName;
private String bodyPart;
private BigDecimal maxWeight;
private Integer repsAtMaxWeight;
private LocalDate achievedDate;
private BigDecimal oneRepMax; // 1RM 계산값
}
설명:
- 개인 최고 기록(PR) 데이터
- 1RM(One Rep Max) 추정값 포함
- BigDecimal로 정확한 무게 표현
2. StatsService - 비즈니스 로직
파일: src/main/java/com/example/FitTracker/service/StatsService.java
주간 통계 계산
public WeeklyStatsResponse getWeeklyStats(Long userId, LocalDate startDate, LocalDate endDate) {
List<WorkoutSession> sessions = workoutSessionRepository
.findByUserIdAndWorkoutDateBetween(userId, startDate, endDate);
long totalWorkouts = sessions.size();
long totalSets = sessions.stream()
.mapToLong(session -> session.getWorkoutSets().size())
.sum();
int totalMinutes = sessions.stream()
.filter(session -> session.getDurationMinutes() != null)
.mapToInt(WorkoutSession::getDurationMinutes)
.sum();
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate) + 1;
double avgWorkoutsPerDay = daysBetween > 0 ?
(double) totalWorkouts / daysBetween : 0.0;
return WeeklyStatsResponse.builder()
.startDate(startDate)
.endDate(endDate)
.totalWorkouts(totalWorkouts)
.totalSets(totalSets)
.totalMinutes(totalMinutes)
.avgWorkoutsPerDay(Math.round(avgWorkoutsPerDay * 100.0) / 100.0)
.build();
}
설명:
- 지정된 기간의 모든 운동 세션 조회
- Stream API로 총 세트 수, 운동 시간 집계
- ChronoUnit으로 일평균 운동 횟수 계산
- 소수점 둘째자리까지 반올림
신체 부위별 통계
public List<BodyPartStatsResponse> getBodyPartStats(Long userId, LocalDate startDate, LocalDate endDate) {
List<Object[]> results = workoutSetRepository
.findBodyPartStats(userId, startDate, endDate);
long totalSets = results.stream()
.mapToLong(result -> (Long) result[1])
.sum();
return results.stream()
.map(result -> {
String bodyPart = (String) result[0];
Long setCount = (Long) result[1];
Double percentage = totalSets > 0 ?
(setCount * 100.0) / totalSets : 0.0;
return BodyPartStatsResponse.builder()
.bodyPart(bodyPart)
.totalSets(setCount)
.percentage(Math.round(percentage * 10.0) / 10.0)
.build();
})
.sorted(Comparator.comparing(BodyPartStatsResponse::getTotalSets).reversed())
.collect(Collectors.toList());
}
설명:
- Repository의 커스텀 쿼리로 집계 데이터 조회
- 전체 세트 수 대비 각 부위 비율 계산
- 세트 수 내림차순 정렬
개인 기록 조회 및 1RM 계산
public List<PersonalRecordResponse> getPersonalRecords(Long userId) {
List<WorkoutSet> allSets = workoutSetRepository.findAllPersonalRecords(userId);
// 운동 종목별로 그룹화
Map<Long, List<WorkoutSet>> setsByExercise = allSets.stream()
.collect(Collectors.groupingBy(set -> set.getExerciseType().getId()));
return setsByExercise.entrySet().stream()
.map(entry -> {
List<WorkoutSet> sets = entry.getValue();
// 최고 무게 세트 찾기
WorkoutSet maxWeightSet = sets.stream()
.max(Comparator.comparing((WorkoutSet set) ->
set.getWeight() != null ? set.getWeight() : BigDecimal.ZERO)
.thenComparing(WorkoutSet::getReps))
.orElse(null);
if (maxWeightSet == null || maxWeightSet.getWeight() == null) {
return null;
}
// 1RM 계산
BigDecimal oneRepMax = calculateOneRepMax(
maxWeightSet.getWeight(),
maxWeightSet.getReps()
);
return PersonalRecordResponse.builder()
.exerciseTypeId(maxWeightSet.getExerciseType().getId())
.exerciseName(maxWeightSet.getExerciseType().getName())
.bodyPart(maxWeightSet.getExerciseType().getBodyPart())
.maxWeight(maxWeightSet.getWeight())
.repsAtMaxWeight(maxWeightSet.getReps())
.achievedDate(maxWeightSet.getWorkoutSession().getWorkoutDate())
.oneRepMax(oneRepMax)
.build();
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(PersonalRecordResponse::getExerciseName))
.collect(Collectors.toList());
}
private BigDecimal calculateOneRepMax(BigDecimal weight, Integer reps) {
if (reps == 1) {
return weight;
}
if (reps >= 37) {
return weight; // 공식 적용 불가
}
// Brzycki 공식: 1RM = weight × (36 / (37 - reps))
BigDecimal divisor = new BigDecimal(37 - reps);
BigDecimal multiplier = new BigDecimal("36").divide(divisor, 2, RoundingMode.HALF_UP);
return weight.multiply(multiplier).setScale(2, RoundingMode.HALF_UP);
}
설명:
- 모든 운동 세트를 운동 종목별로 그룹화
- 각 종목의 최대 무게 기록 추출
- Brzycki 공식으로 1RM(추정 최대 중량) 계산
- BigDecimal로 정확한 실수 연산 수행
3. StatsController - REST API
파일: src/main/java/com/example/FitTracker/controller/StatsController.java
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
@Tag(name = "통계", description = "운동 통계 및 분석 API")
public class StatsController {
private final StatsService statsService;
private final SecurityUtil securityUtil;
@GetMapping("/weekly")
@Operation(summary = "주간 통계 조회")
public ResponseEntity<ApiResponse<WeeklyStatsResponse>> getWeeklyStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
WeeklyStatsResponse stats = statsService.getWeeklyStats(
securityUtil.getCurrentUserId(), startDate, endDate);
return ResponseEntity.ok(ApiResponse.success(stats));
}
@GetMapping("/monthly")
@Operation(summary = "월간 통계 조회")
public ResponseEntity<ApiResponse<MonthlyStatsResponse>> getMonthlyStats(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") YearMonth yearMonth) {
MonthlyStatsResponse stats = statsService.getMonthlyStats(
securityUtil.getCurrentUserId(), yearMonth);
return ResponseEntity.ok(ApiResponse.success(stats));
}
@GetMapping("/body-parts")
@Operation(summary = "신체 부위별 통계")
public ResponseEntity<ApiResponse<List<BodyPartStatsResponse>>> getBodyPartStats(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
List<BodyPartStatsResponse> stats = statsService.getBodyPartStats(
securityUtil.getCurrentUserId(), startDate, endDate);
return ResponseEntity.ok(ApiResponse.success(stats));
}
@GetMapping("/personal-records")
@Operation(summary = "개인 기록 조회")
public ResponseEntity<ApiResponse<List<PersonalRecordResponse>>> getPersonalRecords() {
List<PersonalRecordResponse> records = statsService.getPersonalRecords(
securityUtil.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.success(records));
}
@GetMapping("/personal-records/{exerciseTypeId}")
@Operation(summary = "특정 운동의 개인 기록")
public ResponseEntity<ApiResponse<PersonalRecordResponse>> getPersonalRecordByExercise(
@PathVariable Long exerciseTypeId) {
PersonalRecordResponse record = statsService.getPersonalRecordByExercise(
securityUtil.getCurrentUserId(), exerciseTypeId);
return ResponseEntity.ok(ApiResponse.success(record));
}
}
설명:
- 5개의 통계 조회 엔드포인트 구현
- JWT 토큰으로 현재 사용자 식별
- Swagger 문서화 어노테이션 추가
- 날짜 형식 자동 변환 (@DateTimeFormat)
4. Repository 쿼리 최적화
파일: src/main/java/com/example/FitTracker/repository/WorkoutSetRepository.java
// 전체 개인 기록 조회 (N+1 문제 해결)
@Query("SELECT ws FROM WorkoutSet ws " +
"JOIN FETCH ws.workoutSession session " +
"JOIN FETCH ws.exerciseType et " +
"WHERE session.user.id = :userId " +
"AND ws.weight IS NOT NULL " +
"ORDER BY ws.weight DESC, ws.reps DESC")
List<WorkoutSet> findAllPersonalRecords(@Param("userId") Long userId);
// 특정 운동 기록 조회
@Query("SELECT ws FROM WorkoutSet ws " +
"JOIN FETCH ws.workoutSession session " +
"WHERE session.user.id = :userId " +
"AND ws.exerciseType.id = :exerciseTypeId " +
"AND ws.weight IS NOT NULL " +
"ORDER BY ws.weight DESC, ws.reps DESC")
List<WorkoutSet> findPersonalRecords(
@Param("userId") Long userId,
@Param("exerciseTypeId") Long exerciseTypeId
);
// 신체 부위별 집계
@Query("SELECT et.bodyPart, COUNT(ws) as setCount " +
"FROM WorkoutSet ws " +
"JOIN ws.exerciseType et " +
"JOIN ws.workoutSession session " +
"WHERE session.user.id = :userId " +
"AND session.workoutDate BETWEEN :startDate AND :endDate " +
"GROUP BY et.bodyPart")
List<Object[]> findBodyPartStats(
@Param("userId") Long userId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
설명:
- JPQL의 JOIN FETCH로 연관 엔티티를 한 번에 조회
- N+1 쿼리 문제 사전 방지
- GROUP BY를 활용한 집계 쿼리
- 무게가 NULL인 데이터 제외 (IS NOT NULL)
Git 커밋 내역
commit 1a2b3c4 - feat: Stats DTO 4개 클래스 구현
commit 5d6e7f8 - feat: StatsService 통계 비즈니스 로직 구현
commit 9g0h1i2 - feat: 1RM 계산 기능 추가 (Brzycki 공식)
commit 3j4k5l6 - feat: StatsController REST API 5개 엔드포인트
commit 7m8n9o0 - refactor: WorkoutSetRepository 쿼리 최적화
commit 1p2q3r4 - fix: Import 문제 및 컴파일 오류 수정
commit 5s6t7u8 - docs: Stats 기능 구현 가이드 문서 작성
총 커밋 수: 7개
사용한 기술 및 라이브러리
- Spring Data JPA: Repository 패턴 및 JPQL 쿼리
- Stream API: 데이터 집계 및 변환
- Lombok: @Builder, @Getter 등 보일러플레이트 코드 제거
- BigDecimal: 정확한 실수 연산 (무게 계산)
- Java 8 Time API: LocalDate, YearMonth, ChronoUnit
- Swagger/OpenAPI: API 문서 자동 생성
작업 통계
- 작성한 파일 수: 10개
- DTO 4개
- Service 1개
- Controller 1개
- Repository 수정 1개
- 문서 3개
- 추가된 코드: 약 650줄
- StatsService.java: 230줄
- StatsController.java: 80줄
- DTO 클래스들: 100줄
- Repository 메서드: 40줄
- 문서: 200줄
- 수정된 파일:
- WorkoutSetRepository.java (쿼리 메서드 3개 추가)
- README.md (통계 기능 섹션 추가)
주요 학습 내용
1. Stream API 활용
Collectors.groupingBy()로 데이터 그룹화mapToLong(),sum()으로 집계 연산filter(),map(),sorted()체이닝
2. JPQL 성능 최적화
- JOIN FETCH로 N+1 문제 해결
- GROUP BY를 활용한 집계 쿼리
- 쿼리 결과를 Object[] 배열로 받기
3. BigDecimal 정확한 연산
BigDecimal divisor = new BigDecimal(37 - reps);
BigDecimal multiplier = new BigDecimal("36")
.divide(divisor, 2, RoundingMode.HALF_UP);
- 부동소수점 오차 방지
- RoundingMode로 반올림 정책 명시
- setScale()로 소수점 자릿수 제한
4. 날짜 처리
@DateTimeFormat으로 문자열 → LocalDate 자동 변환ChronoUnit.DAYS.between()으로 날짜 차이 계산- YearMonth 타입으로 년-월 표현
트러블슈팅
문제 1: Stream API 컴파일 오류
증상: setsByExercise.entrySet().stream() 에서 타입 추론 실패
원인:
import java.util.*;와일드카드 import 사용- IDE의 타입 추론 실패
해결:
// Before
import java.util.*;
// After
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
- 명시적 개별 import로 변경
- 타입 추론 문제 해결
문제 2: Lombok @Builder 작동 안 함
증상: PersonalRecordResponse.builder() 메서드가 없다는 오류
원인:
- @NoArgsConstructor 누락
- Lombok Annotation Processor 미활성화
해결:
@Getter
@NoArgsConstructor // ← 추가!
@AllArgsConstructor // ← 추가!
@Builder
public class PersonalRecordResponse {
// ...
}
- @NoArgsConstructor와 @AllArgsConstructor 모두 추가
- IDE에서 Annotation Processing 활성화 확인
문제 3: N+1 쿼리 발생
증상: 개인 기록 조회 시 수백 개의 SELECT 쿼리 발생
원인:
- WorkoutSet → ExerciseType 지연 로딩
- 루프에서 getName() 호출 시 추가 쿼리
해결:
@Query("SELECT ws FROM WorkoutSet ws " +
"JOIN FETCH ws.workoutSession session " +
"JOIN FETCH ws.exerciseType et " + // ← FETCH 추가
"WHERE session.user.id = :userId")
- JOIN FETCH로 연관 엔티티 즉시 로딩
- 단일 쿼리로 모든 데이터 조회
- show-sql로 쿼리 개수 확인
다음 단계 (Day 4 예정)
1. 데이터 초기화
- 운동 종목 마스터 데이터 삽입
- SQL 스크립트 작성 (data.sql)
- 주요 운동 50개 등록
2. 단위 테스트
- StatsService 테스트 작성
- 1RM 계산 로직 검증
- Repository 쿼리 테스트
- Mock 객체를 활용한 단위 테스트
3. 통합 테스트
- Stats API 통합 테스트
- JWT 인증 포함 테스트
- @SpringBootTest 활용
4. API 문서화
- Swagger 상세 설명 추가
- 요청/응답 예시 작성
- Postman 컬렉션 생성
참고 자료
1RM 계산 공식
- Brzycki:
1RM = weight × (36 / (37 - reps)) - Epley:
1RM = weight × (1 + 0.0333 × reps) - 출처: ExRx.net
Spring Data JPA
Stream API
BigDecimal
728x90
'프로젝트' 카테고리의 다른 글
| [운동 루틴 트래커] 프로젝트 Day 4 - 테스트 및 기능 개선 (0) | 2025.12.11 |
|---|---|
| [운동 루틴 트래커] 프로젝트 Day 2 - 개발 (0) | 2025.12.09 |
| [운동 루틴 트래커] 프로젝트 Day 1 - 기획 (0) | 2025.12.08 |
| [게시판] 프로젝트 Day 7 - 최종 코드 (0) | 2025.10.14 |
| [게시판] 프로젝트 Day 6 - Comment Dao, Service (0) | 2025.10.03 |