프로젝트

[운동 루틴 트래커] 프로젝트 Day 3 - 개발

hawon6691 2025. 12. 10. 20:10
728x90

날짜: 2025-12-10
작업 시간: 약 4시간


오늘 구현한 기능

  1. 통계 및 분석 기능 완성 - 주간/월간 통계, 신체 부위별 분석, 개인 기록 추적
  2. Stats API 5개 엔드포인트 구현 - RESTful API 설계 및 구현
  3. 1RM 계산 기능 - Brzycki 공식을 이용한 최대 중량 추정
  4. 통계 쿼리 최적화 - 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