diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..f44b8b4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,39 @@ +language: "ko-KR" + +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: false + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + path_filters: + - "!**/build/**" + - "!**/.venv/**" + - "!**/node_modules/**" + - "!ai-server/exercise_pb2.py" + - "!ai-server/exercise_pb2_grpc.py" + - "!ai-server/app/proto/exercise_pb2.py" + - "!ai-server/app/proto/exercise_pb2_grpc.py" + - "!mysql/**" + path_instructions: + - path: "backend/**/*.java" + instructions: > + Spring Boot 백엔드. 프로덕션 수준으로 리뷰: 트랜잭션 경계, N+1, 인덱스 활용, + 동시성(낙관적 락 등), 예외 처리 일관성, 입력 검증을 중점적으로 본다. + - path: "ai-server/**/*.py" + instructions: > + FastAPI/AI 서버. 이 프로젝트에서는 변경 면적을 최소화하는 방침이라, 리팩터링이나 + 스타일 변경보다 실제 동작 변경 여부와 부작용(특히 세션 상태·gRPC 계약)에 집중해서 본다. + - path: "frontend/**/*.{ts,tsx}" + instructions: > + React Native 프론트엔드. API 계약 일치 여부, 에러 처리, 불필요한 리렌더링을 본다. + - path: "mysql/*.sql" + instructions: > + 스키마/시드 데이터. schema.sql과 data.sql의 컬럼 정의가 어긋나지 않는지, + FK의 ON DELETE 정책이 실제 삭제 흐름과 맞는지 확인한다. + +chat: + auto_reply: true diff --git a/ai-server/app/core/squat_analyzer.py b/ai-server/app/core/squat_analyzer.py index f499acd..2f7ab25 100644 --- a/ai-server/app/core/squat_analyzer.py +++ b/ai-server/app/core/squat_analyzer.py @@ -9,7 +9,11 @@ from app.core.dtw_calculator import compute_sync_rate from app.models.pose import Landmark from app.models.video import SquatAnalysisResult, SquatFrameMetrics -from app.utils.constants import LANDMARK +from app.utils.constants import LANDMARK, SYNC_THRESHOLDS + +# 즉시 수정 필요 컷 / 양호 컷 비율. 기존 고정값(양호>=70, 즉시수정<40)의 비율을 유지한 채 +# 페르소나별 "양호" 기준(SYNC_THRESHOLDS)에 비례 스케일한다. +_LOW_CUT_RATIO = 40 / 70 @dataclass @@ -313,9 +317,12 @@ def _summarize_rep(self, state, last_raw) -> StreamingRepEvent: torsos = [last_raw.torso_tilt] if not state.current_rep_frames else [last_raw.torso_tilt] mean_torso = round(sum(torsos) / len(torsos), 2) - if sync_rate >= 70: + pass_threshold = SYNC_THRESHOLDS.get(state.persona, SYNC_THRESHOLDS["BEGINNER"]) + low_threshold = pass_threshold * _LOW_CUT_RATIO + + if sync_rate >= pass_threshold: msg = "자세 양호" - elif sync_rate >= 40: + elif sync_rate >= low_threshold: msg = "자세 보정 필요" else: msg = "즉시 자세 수정 필요" diff --git a/ai-server/app/grpc/exercise_servicer.py b/ai-server/app/grpc/exercise_servicer.py index 34b9d1e..ae1c338 100644 --- a/ai-server/app/grpc/exercise_servicer.py +++ b/ai-server/app/grpc/exercise_servicer.py @@ -59,10 +59,12 @@ def StartAnalysis(self, request, context): """[Spring → FastAPI] 운동 분석 세션 시작.""" session_id = request.session_id exercise_id = request.exercise_id + persona = request.persona or "BEGINNER" logger.info( - "[Spring → AI] StartAnalysis 수신 (session=%s, exercise=%s, reference_frames=%d)", + "[Spring → AI] StartAnalysis 수신 (session=%s, exercise=%s, persona=%s, reference_frames=%d)", session_id, exercise_id, + persona, len(request.reference_poses), ) @@ -83,6 +85,7 @@ def StartAnalysis(self, request, context): exercise_id=exercise_id, reference_angles=reference_angles, exercise_type=exercise_type, + persona=persona, ) now = Timestamp() diff --git a/ai-server/app/grpc/session_state.py b/ai-server/app/grpc/session_state.py index 154207a..2e84759 100644 --- a/ai-server/app/grpc/session_state.py +++ b/ai-server/app/grpc/session_state.py @@ -33,6 +33,7 @@ class SessionState: session_id: int exercise_id: int exercise_type: str = "squat" + persona: str = "BEGINNER" reference_angles: list[list[float]] = field(default_factory=list) # 진행 중인 rep에 누적되는 프레임들 @@ -63,12 +64,14 @@ def create( exercise_id: int, reference_angles: list[list[float]], exercise_type: str = "squat", + persona: str = "BEGINNER", ) -> SessionState: with self._lock: state = SessionState( session_id=session_id, exercise_id=exercise_id, exercise_type=exercise_type, + persona=persona, reference_angles=reference_angles, ) self._sessions[session_id] = state diff --git a/ai-server/app/proto/exercise.proto b/ai-server/app/proto/exercise.proto index a02867e..c7edc96 100644 --- a/ai-server/app/proto/exercise.proto +++ b/ai-server/app/proto/exercise.proto @@ -60,6 +60,8 @@ message AnalyzeRequest { string reference_source = 2; // [핵심] 스프링 DB에서 조회한 기준 좌표 리스트를 여기에 담아서 보냅니다. repeated PoseDataRequest reference_poses = 4; + // 페르소나별 싱크로율 임계값 계산용 (BEGINNER/ADVANCED/DIET/REHAB). 빈 문자열이면 AI측에서 BEGINNER로 취급. + string persona = 5; } message AnalyzeResponse { diff --git a/ai-server/app/utils/constants.py b/ai-server/app/utils/constants.py index b7d7162..6450a52 100644 --- a/ai-server/app/utils/constants.py +++ b/ai-server/app/utils/constants.py @@ -40,10 +40,10 @@ ], } -# 동기화율 임계값 (페르소나별) +# 동기화율 임계값 (페르소나별). sync_rate와 동일하게 0~100 스케일. SYNC_THRESHOLDS = { - "BEGINNER": 0.60, - "ADVANCED": 0.85, - "DIET": 0.70, - "REHAB": 0.50, + "BEGINNER": 60.0, + "ADVANCED": 85.0, + "DIET": 70.0, + "REHAB": 50.0, } diff --git a/ai-server/exercise_pb2.py b/ai-server/exercise_pb2.py index aece978..9d17704 100644 --- a/ai-server/exercise_pb2.py +++ b/ai-server/exercise_pb2.py @@ -15,7 +15,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65xercise.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"e\n\x0e\x45xtractRequest\x12\x13\n\x0b\x65xercise_id\x18\x01 \x01(\x03\x12\x13\n\x0byoutube_url\x18\x02 \x01(\t\x12)\n\x0f\x65xtracted_poses\x18\x03 \x03(\x0b\x32\x10.PoseDataRequest\"b\n\x0f\x45xtractResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0b\x65xercise_id\x18\x02 \x01(\x03\x12)\n\x0f\x65xtracted_poses\x18\x03 \x03(\x0b\x32\x10.PoseDataRequest\"~\n\x0e\x41nalyzeRequest\x12\x13\n\x0b\x65xercise_id\x18\x01 \x01(\x03\x12\x12\n\nsession_id\x18\x03 \x01(\x03\x12\x18\n\x10reference_source\x18\x02 \x01(\t\x12)\n\x0freference_poses\x18\x04 \x03(\x0b\x32\x10.PoseDataRequest\"\x9b\x01\n\x0f\x41nalyzeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x12\n\nsession_id\x18\x02 \x01(\x03\x12\x13\n\x0b\x65xercise_id\x18\x03 \x01(\x03\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1e\n\x06status\x18\x05 \x01(\x0e\x32\x0e.SessionStatus\"\xb8\x01\n\x16SessionCompleteRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x12\n\ntotal_reps\x18\x02 \x01(\x05\x12\x15\n\ravg_sync_rate\x18\x03 \x01(\x01\x12\x15\n\rmax_sync_rate\x18\x04 \x01(\x01\x12\x15\n\rmin_sync_rate\x18\x05 \x01(\x01\x12\x17\n\x0f\x63\x61lories_burned\x18\x06 \x01(\x01\x12\x18\n\x10\x64ifficulty_level\x18\x07 \x01(\x05\"{\n\x17SessionCompleteResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x1e\n\x06status\x18\x02 \x01(\x0e\x32\x0e.SessionStatus\x12,\n\x08\x65nd_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"p\n\x0fPoseDataRequest\x12\x15\n\rtimestamp_sec\x18\x01 \x01(\x01\x12\x19\n\x11joint_coordinates\x18\x02 \x01(\t\x12\x11\n\tsync_rate\x18\x03 \x01(\x01\x12\x18\n\x10\x66\x65\x65\x64\x62\x61\x63k_message\x18\x04 \x01(\t\"O\n\x14PoseDataBatchRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12#\n\tpose_data\x18\x02 \x03(\x0b\x32\x10.PoseDataRequest\"i\n\x10PoseDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x12\n\nsession_id\x18\x02 \x01(\x03\x12\x15\n\rtimestamp_sec\x18\x03 \x01(\x01\x12\x19\n\x11joint_coordinates\x18\x04 \x01(\t\"!\n\x0bStopRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\"D\n\x0cStopResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\x03\"u\n\rFeedbackEvent\x12\x15\n\rfeedback_type\x18\x01 \x01(\t\x12\x1c\n\x14sync_rate_at_trigger\x18\x02 \x01(\x01\x12/\n\x0boccurred_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"l\n\x14\x46\x65\x65\x64\x62\x61\x63kBatchRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x0e\n\x06set_no\x18\x02 \x01(\x05\x12\x10\n\x08is_final\x18\x03 \x01(\x08\x12\x1e\n\x06\x65vents\x18\x04 \x03(\x0b\x32\x0e.FeedbackEvent\"@\n\x15\x46\x65\x65\x64\x62\x61\x63kBatchResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x13\n\x0bsaved_count\x18\x02 \x01(\x05*;\n\rSessionStatus\x12\x0f\n\x0bIN_PROGRESS\x10\x00\x12\r\n\tCOMPLETED\x10\x01\x12\n\n\x06\x46\x41ILED\x10\x02\x32\xf9\x02\n\x0f\x45xerciseService\x12\x39\n\x14\x45xtractReferenceData\x12\x0f.ExtractRequest\x1a\x10.ExtractResponse\x12\x32\n\rStartAnalysis\x12\x0f.AnalyzeRequest\x1a\x10.AnalyzeResponse\x12=\n\x11SavePoseDataBatch\x12\x15.PoseDataBatchRequest\x1a\x11.PoseDataResponse\x12\x45\n\x10\x43ompleteAnalysis\x12\x17.SessionCompleteRequest\x1a\x18.SessionCompleteResponse\x12+\n\x0cStopAnalysis\x12\x0c.StopRequest\x1a\r.StopResponse\x12\x44\n\x13ReportFeedbackBatch\x12\x15.FeedbackBatchRequest\x1a\x16.FeedbackBatchResponseB%\n\x12\x63om.shadowfit.grpcB\rExerciseProtoP\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0e\x65xercise.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"e\n\x0e\x45xtractRequest\x12\x13\n\x0b\x65xercise_id\x18\x01 \x01(\x03\x12\x13\n\x0byoutube_url\x18\x02 \x01(\t\x12)\n\x0f\x65xtracted_poses\x18\x03 \x03(\x0b\x32\x10.PoseDataRequest\"b\n\x0f\x45xtractResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x13\n\x0b\x65xercise_id\x18\x02 \x01(\x03\x12)\n\x0f\x65xtracted_poses\x18\x03 \x03(\x0b\x32\x10.PoseDataRequest\"\x8f\x01\n\x0e\x41nalyzeRequest\x12\x13\n\x0b\x65xercise_id\x18\x01 \x01(\x03\x12\x12\n\nsession_id\x18\x03 \x01(\x03\x12\x18\n\x10reference_source\x18\x02 \x01(\t\x12)\n\x0freference_poses\x18\x04 \x03(\x0b\x32\x10.PoseDataRequest\x12\x0f\n\x07persona\x18\x05 \x01(\t\"\x9b\x01\n\x0f\x41nalyzeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x12\n\nsession_id\x18\x02 \x01(\x03\x12\x13\n\x0b\x65xercise_id\x18\x03 \x01(\x03\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x1e\n\x06status\x18\x05 \x01(\x0e\x32\x0e.SessionStatus\"\xb8\x01\n\x16SessionCompleteRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x12\n\ntotal_reps\x18\x02 \x01(\x05\x12\x15\n\ravg_sync_rate\x18\x03 \x01(\x01\x12\x15\n\rmax_sync_rate\x18\x04 \x01(\x01\x12\x15\n\rmin_sync_rate\x18\x05 \x01(\x01\x12\x17\n\x0f\x63\x61lories_burned\x18\x06 \x01(\x01\x12\x18\n\x10\x64ifficulty_level\x18\x07 \x01(\x05\"{\n\x17SessionCompleteResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x1e\n\x06status\x18\x02 \x01(\x0e\x32\x0e.SessionStatus\x12,\n\x08\x65nd_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"p\n\x0fPoseDataRequest\x12\x15\n\rtimestamp_sec\x18\x01 \x01(\x01\x12\x19\n\x11joint_coordinates\x18\x02 \x01(\t\x12\x11\n\tsync_rate\x18\x03 \x01(\x01\x12\x18\n\x10\x66\x65\x65\x64\x62\x61\x63k_message\x18\x04 \x01(\t\"O\n\x14PoseDataBatchRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12#\n\tpose_data\x18\x02 \x03(\x0b\x32\x10.PoseDataRequest\"i\n\x10PoseDataResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x12\n\nsession_id\x18\x02 \x01(\x03\x12\x15\n\rtimestamp_sec\x18\x03 \x01(\x01\x12\x19\n\x11joint_coordinates\x18\x04 \x01(\t\"!\n\x0bStopRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\"D\n\x0cStopResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x12\n\nsession_id\x18\x03 \x01(\x03\"u\n\rFeedbackEvent\x12\x15\n\rfeedback_type\x18\x01 \x01(\t\x12\x1c\n\x14sync_rate_at_trigger\x18\x02 \x01(\x01\x12/\n\x0boccurred_at\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"l\n\x14\x46\x65\x65\x64\x62\x61\x63kBatchRequest\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x0e\n\x06set_no\x18\x02 \x01(\x05\x12\x10\n\x08is_final\x18\x03 \x01(\x08\x12\x1e\n\x06\x65vents\x18\x04 \x03(\x0b\x32\x0e.FeedbackEvent\"@\n\x15\x46\x65\x65\x64\x62\x61\x63kBatchResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x03\x12\x13\n\x0bsaved_count\x18\x02 \x01(\x05*;\n\rSessionStatus\x12\x0f\n\x0bIN_PROGRESS\x10\x00\x12\r\n\tCOMPLETED\x10\x01\x12\n\n\x06\x46\x41ILED\x10\x02\x32\xf9\x02\n\x0f\x45xerciseService\x12\x39\n\x14\x45xtractReferenceData\x12\x0f.ExtractRequest\x1a\x10.ExtractResponse\x12\x32\n\rStartAnalysis\x12\x0f.AnalyzeRequest\x1a\x10.AnalyzeResponse\x12=\n\x11SavePoseDataBatch\x12\x15.PoseDataBatchRequest\x1a\x11.PoseDataResponse\x12\x45\n\x10\x43ompleteAnalysis\x12\x17.SessionCompleteRequest\x1a\x18.SessionCompleteResponse\x12+\n\x0cStopAnalysis\x12\x0c.StopRequest\x1a\r.StopResponse\x12\x44\n\x13ReportFeedbackBatch\x12\x15.FeedbackBatchRequest\x1a\x16.FeedbackBatchResponseB%\n\x12\x63om.shadowfit.grpcB\rExerciseProtoP\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -23,36 +23,36 @@ if _descriptor._USE_C_DESCRIPTORS == False: _globals['DESCRIPTOR']._options = None _globals['DESCRIPTOR']._serialized_options = b'\n\022com.shadowfit.grpcB\rExerciseProtoP\001' - _globals['_SESSIONSTATUS']._serialized_start=1554 - _globals['_SESSIONSTATUS']._serialized_end=1613 + _globals['_SESSIONSTATUS']._serialized_start=1572 + _globals['_SESSIONSTATUS']._serialized_end=1631 _globals['_EXTRACTREQUEST']._serialized_start=51 _globals['_EXTRACTREQUEST']._serialized_end=152 _globals['_EXTRACTRESPONSE']._serialized_start=154 _globals['_EXTRACTRESPONSE']._serialized_end=252 - _globals['_ANALYZEREQUEST']._serialized_start=254 - _globals['_ANALYZEREQUEST']._serialized_end=380 - _globals['_ANALYZERESPONSE']._serialized_start=383 - _globals['_ANALYZERESPONSE']._serialized_end=538 - _globals['_SESSIONCOMPLETEREQUEST']._serialized_start=541 - _globals['_SESSIONCOMPLETEREQUEST']._serialized_end=725 - _globals['_SESSIONCOMPLETERESPONSE']._serialized_start=727 - _globals['_SESSIONCOMPLETERESPONSE']._serialized_end=850 - _globals['_POSEDATAREQUEST']._serialized_start=852 - _globals['_POSEDATAREQUEST']._serialized_end=964 - _globals['_POSEDATABATCHREQUEST']._serialized_start=966 - _globals['_POSEDATABATCHREQUEST']._serialized_end=1045 - _globals['_POSEDATARESPONSE']._serialized_start=1047 - _globals['_POSEDATARESPONSE']._serialized_end=1152 - _globals['_STOPREQUEST']._serialized_start=1154 - _globals['_STOPREQUEST']._serialized_end=1187 - _globals['_STOPRESPONSE']._serialized_start=1189 - _globals['_STOPRESPONSE']._serialized_end=1257 - _globals['_FEEDBACKEVENT']._serialized_start=1259 - _globals['_FEEDBACKEVENT']._serialized_end=1376 - _globals['_FEEDBACKBATCHREQUEST']._serialized_start=1378 - _globals['_FEEDBACKBATCHREQUEST']._serialized_end=1486 - _globals['_FEEDBACKBATCHRESPONSE']._serialized_start=1488 - _globals['_FEEDBACKBATCHRESPONSE']._serialized_end=1552 - _globals['_EXERCISESERVICE']._serialized_start=1616 - _globals['_EXERCISESERVICE']._serialized_end=1993 + _globals['_ANALYZEREQUEST']._serialized_start=255 + _globals['_ANALYZEREQUEST']._serialized_end=398 + _globals['_ANALYZERESPONSE']._serialized_start=401 + _globals['_ANALYZERESPONSE']._serialized_end=556 + _globals['_SESSIONCOMPLETEREQUEST']._serialized_start=559 + _globals['_SESSIONCOMPLETEREQUEST']._serialized_end=743 + _globals['_SESSIONCOMPLETERESPONSE']._serialized_start=745 + _globals['_SESSIONCOMPLETERESPONSE']._serialized_end=868 + _globals['_POSEDATAREQUEST']._serialized_start=870 + _globals['_POSEDATAREQUEST']._serialized_end=982 + _globals['_POSEDATABATCHREQUEST']._serialized_start=984 + _globals['_POSEDATABATCHREQUEST']._serialized_end=1063 + _globals['_POSEDATARESPONSE']._serialized_start=1065 + _globals['_POSEDATARESPONSE']._serialized_end=1170 + _globals['_STOPREQUEST']._serialized_start=1172 + _globals['_STOPREQUEST']._serialized_end=1205 + _globals['_STOPRESPONSE']._serialized_start=1207 + _globals['_STOPRESPONSE']._serialized_end=1275 + _globals['_FEEDBACKEVENT']._serialized_start=1277 + _globals['_FEEDBACKEVENT']._serialized_end=1394 + _globals['_FEEDBACKBATCHREQUEST']._serialized_start=1396 + _globals['_FEEDBACKBATCHREQUEST']._serialized_end=1504 + _globals['_FEEDBACKBATCHRESPONSE']._serialized_start=1506 + _globals['_FEEDBACKBATCHRESPONSE']._serialized_end=1570 + _globals['_EXERCISESERVICE']._serialized_start=1634 + _globals['_EXERCISESERVICE']._serialized_end=2011 # @@protoc_insertion_point(module_scope) diff --git a/backend/src/main/java/com/shadowfit/controller/AdminExerciseController.java b/backend/src/main/java/com/shadowfit/controller/AdminExerciseController.java index d1226f9..607bfda 100644 --- a/backend/src/main/java/com/shadowfit/controller/AdminExerciseController.java +++ b/backend/src/main/java/com/shadowfit/controller/AdminExerciseController.java @@ -20,7 +20,7 @@ public class AdminExerciseController { private final AdminExerciseService adminExerciseService; @Operation(summary = "운동 싱크로율 임계값 변경", - description = "초보자/고급자 임계값을 즉시 갱신. 신규 세션부터 적용. beginner < advanced 필수.") + description = "4개 페르소나(초보자/고급자/다이어트/재활) 임계값을 즉시 갱신. 신규 세션부터 적용. beginner < advanced 필수.") @PatchMapping("/{exerciseId}/thresholds") public ResponseEntity updateThresholds( @PathVariable Long exerciseId, diff --git a/backend/src/main/java/com/shadowfit/dto/admin/ExerciseThresholdResponseDto.java b/backend/src/main/java/com/shadowfit/dto/admin/ExerciseThresholdResponseDto.java index 2b1f120..378d66a 100644 --- a/backend/src/main/java/com/shadowfit/dto/admin/ExerciseThresholdResponseDto.java +++ b/backend/src/main/java/com/shadowfit/dto/admin/ExerciseThresholdResponseDto.java @@ -8,14 +8,18 @@ public record ExerciseThresholdResponseDto( Long exerciseId, String name, BigDecimal syncThresholdBeginner, - BigDecimal syncThresholdAdvanced + BigDecimal syncThresholdAdvanced, + BigDecimal syncThresholdDiet, + BigDecimal syncThresholdRehab ) { public static ExerciseThresholdResponseDto fromEntity(Exercise e) { return new ExerciseThresholdResponseDto( e.getId(), e.getName(), e.getSyncThresholdBeginner(), - e.getSyncThresholdAdvanced() + e.getSyncThresholdAdvanced(), + e.getSyncThresholdDiet(), + e.getSyncThresholdRehab() ); } } \ No newline at end of file diff --git a/backend/src/main/java/com/shadowfit/dto/admin/ThresholdUpdateDto.java b/backend/src/main/java/com/shadowfit/dto/admin/ThresholdUpdateDto.java index c48cadb..fb195b9 100644 --- a/backend/src/main/java/com/shadowfit/dto/admin/ThresholdUpdateDto.java +++ b/backend/src/main/java/com/shadowfit/dto/admin/ThresholdUpdateDto.java @@ -15,6 +15,16 @@ public record ThresholdUpdateDto( @NotNull @DecimalMin(value = "0.0", message = "고급자 임계값은 0 이상이어야 합니다") @DecimalMax(value = "100.0", message = "고급자 임계값은 100 이하여야 합니다") - BigDecimal advanced + BigDecimal advanced, + + @NotNull + @DecimalMin(value = "0.0", message = "다이어트 임계값은 0 이상이어야 합니다") + @DecimalMax(value = "100.0", message = "다이어트 임계값은 100 이하여야 합니다") + BigDecimal diet, + + @NotNull + @DecimalMin(value = "0.0", message = "재활 임계값은 0 이상이어야 합니다") + @DecimalMax(value = "100.0", message = "재활 임계값은 100 이하여야 합니다") + BigDecimal rehab ) { } \ No newline at end of file diff --git a/backend/src/main/java/com/shadowfit/model/exercise/Exercise.java b/backend/src/main/java/com/shadowfit/model/exercise/Exercise.java index bfed794..7bdbe0c 100644 --- a/backend/src/main/java/com/shadowfit/model/exercise/Exercise.java +++ b/backend/src/main/java/com/shadowfit/model/exercise/Exercise.java @@ -48,6 +48,14 @@ public class Exercise { @Column(precision = 5, scale = 2) private BigDecimal syncThresholdAdvanced = new BigDecimal("85.00"); + @Builder.Default + @Column(precision = 5, scale = 2) + private BigDecimal syncThresholdDiet = new BigDecimal("70.00"); + + @Builder.Default + @Column(precision = 5, scale = 2) + private BigDecimal syncThresholdRehab = new BigDecimal("50.00"); + @Builder.Default @Column(nullable = false) private Integer expectedDurationMinutes = 15; // 예상 운동시간 (기본값: 15분) diff --git a/backend/src/main/java/com/shadowfit/service/Exercise/AdminExerciseService.java b/backend/src/main/java/com/shadowfit/service/Exercise/AdminExerciseService.java index a488b6e..bd8183a 100644 --- a/backend/src/main/java/com/shadowfit/service/Exercise/AdminExerciseService.java +++ b/backend/src/main/java/com/shadowfit/service/Exercise/AdminExerciseService.java @@ -23,6 +23,8 @@ public class AdminExerciseService { @Transactional @CacheEvict(cacheNames = "exercises", key = "#exerciseId") public ExerciseThresholdResponseDto updateThresholds(Long exerciseId, ThresholdUpdateDto dto) { + // beginner < advanced만 검증. diet/rehab은 숙련도 축이 아니라 목적(체중감량/안전)이 달라 + // beginner·advanced와 순서 관계를 강제할 이유가 없음 — 개별 범위(0~100)만 DTO에서 검증. if (dto.beginner().compareTo(dto.advanced()) >= 0) { throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); } @@ -30,13 +32,17 @@ public ExerciseThresholdResponseDto updateThresholds(Long exerciseId, ThresholdU Exercise exercise = exercisesRepository.findById(exerciseId) .orElseThrow(() -> new BusinessException(ErrorCode.EXERCISE_NOT_FOUND)); - log.info("운동 {} 임계값 변경: beginner {} -> {}, advanced {} -> {}", + log.info("운동 {} 임계값 변경: beginner {} -> {}, advanced {} -> {}, diet {} -> {}, rehab {} -> {}", exerciseId, exercise.getSyncThresholdBeginner(), dto.beginner(), - exercise.getSyncThresholdAdvanced(), dto.advanced()); + exercise.getSyncThresholdAdvanced(), dto.advanced(), + exercise.getSyncThresholdDiet(), dto.diet(), + exercise.getSyncThresholdRehab(), dto.rehab()); exercise.setSyncThresholdBeginner(dto.beginner()); exercise.setSyncThresholdAdvanced(dto.advanced()); + exercise.setSyncThresholdDiet(dto.diet()); + exercise.setSyncThresholdRehab(dto.rehab()); return ExerciseThresholdResponseDto.fromEntity(exercise); } diff --git a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java index b01a96a..3731eef 100644 --- a/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java +++ b/backend/src/main/java/com/shadowfit/service/Exercise/ExerciseAnalysisService.java @@ -146,7 +146,7 @@ public Long startAnalysis(VideoRequestDto appDto, Long currentMemberId) { Long sessionId = savedSession.getId(); // 비동기로 FastAPI에 분석 요청 - this.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl); + this.sendAnalysisRequestToFastApi(sessionId, appDto, finalUrl, member.getSelectedPersona().name()); return sessionId; } @@ -157,7 +157,7 @@ public Long startAnalysis(VideoRequestDto appDto, Long currentMemberId) { */ @Async @Transactional(readOnly = true) - public void sendAnalysisRequestToFastApi(Long sessionId, VideoRequestDto appDto, String finalUrl) { + public void sendAnalysisRequestToFastApi(Long sessionId, VideoRequestDto appDto, String finalUrl, String persona) { log.info("비동기 분석 요청 시작 - 세션 ID: {}", sessionId); List referencePoses = referenceRepository.findByExerciseId(appDto.getExerciseId()); @@ -165,7 +165,8 @@ public void sendAnalysisRequestToFastApi(Long sessionId, VideoRequestDto appDto, AnalyzeRequest.Builder requestBuilder = AnalyzeRequest.newBuilder() .setExerciseId(appDto.getExerciseId()) .setSessionId(sessionId) - .setReferenceSource(finalUrl); + .setReferenceSource(finalUrl) + .setPersona(persona); for (ExerciseReference ref : referencePoses) { requestBuilder.addReferencePoses(PoseDataRequest.newBuilder() diff --git a/backend/src/main/proto/exercise.proto b/backend/src/main/proto/exercise.proto index a02867e..c7edc96 100644 --- a/backend/src/main/proto/exercise.proto +++ b/backend/src/main/proto/exercise.proto @@ -60,6 +60,8 @@ message AnalyzeRequest { string reference_source = 2; // [핵심] 스프링 DB에서 조회한 기준 좌표 리스트를 여기에 담아서 보냅니다. repeated PoseDataRequest reference_poses = 4; + // 페르소나별 싱크로율 임계값 계산용 (BEGINNER/ADVANCED/DIET/REHAB). 빈 문자열이면 AI측에서 BEGINNER로 취급. + string persona = 5; } message AnalyzeResponse { diff --git a/docker-compose.yml b/docker-compose.yml index 19affef..6a8c80a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,11 @@ services: - "${MYSQL_PORT:-3306}:3306" volumes: - mysql_data:/var/lib/mysql - - ./mysql/schema.sql:/docker-entrypoint-initdb.d/schema.sql - - ./mysql/data.sql:/docker-entrypoint-initdb.d/data.sql + # docker-entrypoint-initdb.d 는 파일을 알파벳 순으로 실행 — data.sql이 schema.sql보다 + # 먼저 실행되면 안 되므로(테이블 구조 없이 INSERT 불가) 01-/02- 접두어로 순서 강제. + # (data.sql은 순수 시드 INSERT만 담당, CREATE TABLE은 schema.sql이 전담) + - ./mysql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + - ./mysql/data.sql:/docker-entrypoint-initdb.d/02-data.sql - ./mysql/my.cnf:/etc/mysql/conf.d/charset.cnf:ro command: > --character-set-server=utf8mb4 diff --git a/docs/13-docker-setup.md b/docs/13-docker-setup.md index d67f6b2..72750c8 100644 --- a/docs/13-docker-setup.md +++ b/docs/13-docker-setup.md @@ -34,8 +34,9 @@ services: - "${MYSQL_PORT:-3306}:3306" volumes: - mysql_data:/var/lib/mysql - - ./mysql/schema.sql:/docker-entrypoint-initdb.d/schema.sql - - ./mysql/data.sql:/docker-entrypoint-initdb.d/data.sql + # 01-/02- 접두어로 실행 순서 강제 (data.sql은 순수 시드, schema.sql이 먼저 돌아야 함. 2026-07-22) + - ./mysql/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + - ./mysql/data.sql:/docker-entrypoint-initdb.d/02-data.sql - ./mysql/my.cnf:/etc/mysql/conf.d/charset.cnf:ro # 한글 charset 강제 (커밋 0fe056e) command: > --character-set-server=utf8mb4 diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..f6f6735 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,165 @@ +# ShadowFit PRD (Product Requirements Document) + +작성: 2026-07-22 +상태: 초안 — 기존 코드/문서(`REQUIREMENTS.md`, `01-project-overview.md`, `tasks/20-feature-roadmap.md`, `tasks/27-implementation-gaps.md`)와 캡스톤 발표자료 원본 "요구사항 정의"(HO-PT 6쪽, 바탕화면 `남은거.PNG`)를 근거로 역작성. 신규 요구사항이 아니라 **현재 프로젝트를 PRD 형식으로 정리**한 문서. + +--- + +## 1. 배경 / 문제 정의 + +홈트레이닝 이용자는 자세 교정을 받을 트레이너가 없어 잘못된 자세로 반복 운동하기 쉽고, 이는 운동 효과 저하와 부상 위험으로 이어진다. 기존 홈트 앱 대부분은 정해진 루틴 재생 + 수동 기록에 그치고, 실시간으로 "지금 내 자세가 얼마나 정확한지"를 알려주지 않는다. + +ShadowFit은 사용자가 지정한 기준 영상(로컬 또는 YouTube)과 사용자의 실시간 카메라 자세를 비교해 **싱크로율**을 계산하고, 그 결과를 음성(TTS)과 기록(리포트)으로 돌려주는 실시간 운동 자세 교정 앱이다. + +## 2. 목표 (Goals) + +1. 사용자가 기준 동작 대비 자신의 자세 정확도(싱크로율)를 실시간으로 확인할 수 있다. +2. 자세 오류(무릎 방향, 골반 높이 등)를 감지해 음성으로 즉시 안내한다. +3. 운동 세션을 기록하고, 캘린더/주간 요약/세션 상세 리포트로 되돌아볼 수 있다. +4. 사용자의 목적(헬린이/헬창/다이어트/재활)에 맞춰 피드백 톤과 싱크로율 기준을 다르게 적용한다. +5. 세션 lifecycle(시작~진행~종료~타임아웃)을 서버가 신뢰성 있게 관리한다 — 클라이언트나 AI 서버 장애로 세션이 영구 IN_PROGRESS로 남지 않는다. + +## 3. 비목표 (Non-goals, 이번 스코프에서 하지 않음) + +- 스쿼트 외 운동 종목(런지, 플랭크 등) 확장 — [`project_squat_first`] 방침, 2학기 콘텐츠 +- 실시간 소셜 기능(친구, 랭킹, 공유) +- 결제/구독 모델 +- 다국어 지원 — 사용자 노출 텍스트는 한국어 단일 ([`project_korean_only`]) +- 웨어러블 기기 연동 + +## 4. 타겟 사용자 (페르소나) + +출처: `docs/12-persona-difficulty.md` + +| 페르소나 | 대상 | 싱크로율 기준 | 특징 | +|---|---|---|---| +| 헬린이 (BEGINNER) | 운동 초심자 | 60%↑ | 직관적 가이드, 격려 위주 피드백 | +| 헬창 (ADVANCED) | 중/상급자 | 85%↑ | ROM 분석, 좌우 편차 수치 제공 | +| 다이어트 (DIET) | 체중 감량 목적 | 70%↑ | 칼로리 소모 중심 피드백 | +| 재활 (REHAB) | 부상 회복 중 | 50%↑ | 안전 범위 초과 경고, 저강도 | + +## 5. 핵심 요구사항 + +기준 분류는 캡스톤 발표자료 원본 "요구사항 정의"(HO-PT 6쪽)의 6개 카테고리를 그대로 사용. 원본 표시는 **굵게 = 발표 시점(2026-05-23 이전) 구현 완료**, **회색 = 발표 시점 계획 단계**였음을 뜻하며, 우선순위(MoSCoW)와는 별개다 — 예를 들어 "AI 분석 서버 연동"은 발표 시점엔 회색(미완료)이었지만 데모 핵심 흐름이라 우선순위는 Must다. + +현재 상태는 코드 기준 (✅ 완료 / 🟡 부분 / ⬜ 미착수). 출처: `REQUIREMENTS.md`, `tasks/20-feature-roadmap.md`(2026-05-23), `tasks/27-implementation-gaps.md`(2026-07-20, 최신). 프론트엔드 연동 세부 상태는 2026-05-23 이후 갱신된 문서가 없어 **검증 필요**로 표시. + +**담당 열 표기**: 이 프로젝트는 Spring(본인) / FastAPI(팀원) / React Native(팀원) 3자 분업([`docs/handoff/3way-meeting-agenda.md`]). "✅ 완료" 항목은 담당을 표기하지 않음(이미 끝남). 남은 작업(🟡/⬜)만 **누가 할 일인지**를 표시 — **나** = 본인(Backend/Spring), **팀원(FE)** = React Native 담당자, **팀원(AI)** = FastAPI 담당자, **나+팀원(FE)** = 백엔드 API는 내가, 화면 연동은 팀원이 해야 함. 근거: `tasks/21-task-assignment.md`(FE-*/BE-*/AI-* 작업 ID 매핑). + +### 5-1. 회원 및 인증 / 온보딩 / 마이페이지 + +| 기능명 | 발표 시점 표시 | 우선순위 | 현재 상태 | 담당(남은 작업) | 비고 | +|---|---|---|---|---|---| +| 회원가입 | 굵게(완료) | Must | ✅ | — | `POST /member/signup` | +| 로그인 | 굵게(완료) | Must | ✅ | — | JWT 발급, `RefreshToken`/`JwtBlacklist`로 로그아웃 무효화(원본 PPT엔 로그아웃 별도 항목 없음) | +| 온보딩 설정 | 굵게(완료) | Must | ✅ | — | 페르소나·신체정보·선호영상 단계별 PATCH | +| 온보딩 조회 | 굵게(완료) | Must | ✅ | — | | +| 온보딩 수정 | 굵게(완료) | Must | ✅ | — | | + +### 5-2. 실시간 운동 및 AI 운동 분석 + +| 기능명 | 발표 시점 표시 | 우선순위 | 현재 상태 | 담당(남은 작업) | 비고 | +|---|---|---|---|---|---| +| 운동 시작 기능 | 굵게(완료) | Must | ✅(백엔드) / 🟡(프론트 연동 검증 필요) | 팀원(FE) — FE-01·02 | `POST /exercises/sessions` | +| 운동 중단 기능 | 굵게(완료) | Must | ✅(백엔드) / 🟡(프론트) | 팀원(FE) — FE-02 | | +| 운동 횟수 자동 측정 기능 | 굵게(완료) | Must | ✅ | — | AI 서버 rep count | +| 운동 구간별 자세 분석 | 굵게(완료) | Must | ✅ | — | rep 단위 sync_rate | +| AI 분석 서버 연동 | 회색(계획) | Must | ✅ | — | gRPC 결합·인증 완료 | +| 실시간 운동 데이터 전송 기능 | 회색(계획) | Must | ✅ | — | AI→Spring 배치 저장 | +| 운동 데이터 저장 | 회색(계획) | Must | ✅ | — | `pose_data` | +| 운동 완료 결과 전송 | 회색(계획) | Must | ✅ | — | `CompleteAnalysis` | +| 운동 자세 유사도 분석 | 회색(계획) | Must | ✅ | — | DTW (`dtw_calculator.py`) | +| 개인화 TTS 피드백 | 회색(계획) | Should | 🟡 | 팀원(FE) — FE-07 | 서버 템플릿 8종 완료(나), 디바이스 TTS 재생부만 남음 | +| 운동 타이머 기능 | 회색(계획) | Should | 🟡 | 팀원(FE) — FE-12 | 백엔드 `start_time`/`end_time` 이미 있음(나 몫 없음) | +| 관절 색깔 시각화 | 회색(계획) | Should | 🟡 | 팀원(FE) — FE-08 | AI가 landmarks는 이미 반환(팀원 AI 몫 없음), 오버레이만 프론트 작업 | +| 운동 세트 자동 구분 | 회색(계획) | 보류 | ⬜ | 나+팀원(AI) — BE-09+AI-03 | DB에 set 개념 자체 없음. [`project_squat_first`] 방침상 후순위, 새 스키마(나)+분석기(팀원 AI) 협의 필요 | +| 사용자 운동 패턴 분석 | 회색(계획) | 2학기 | ⬜ | 나 — BE-07 | 백엔드 분석 로직 자체가 없음 | + +### 5-3. 운동 리포트 + +| 기능명 | 발표 시점 표시 | 우선순위 | 현재 상태 | 담당(남은 작업) | 비고 | +|---|---|---|---|---|---| +| 메인 캘린더 대시보드 | 굵게(완료) | Must | ✅ | — | `GET /reports/calendar` | +| 주간 운동 요약 통계 | 굵게(완료) | Must | ✅ | — | `GET /reports/weekly-summary` | +| 실시간 자세 피드백 | 회색(계획) | Should | 🟡 | 팀원(FE) — FE-07 | 서버 생성은 완료(나 몫 없음), 실시간 표시/발화만 프론트 | +| 이전 운동 기록 비교 | 회색(계획) | Should | 🟡 | 팀원(FE) — FE-11 | `comparison_with_previous` JSON 이미 있음(나 몫 없음), 화면만 프론트 | +| 운동 worst 구간 선정 | 회색(계획) | Must | 🟡 | 나 — BE-02 | 조회 시 즉석 재계산(`selectWorstSection`) — precompute-on-write 로직 보강, [`tasks/27-implementation-gaps.md`] §1 | +| AI 리포트 자동 생성 | 회색(계획) | Could | ⬜ | 나 — BE-03 | `OPENAI_API_KEY`만 준비, `GptFeedbackService` 신설 필요 | +| 데이터 기반 개인화 루틴 추천 | 회색(계획) | 2학기 | ⬜ | 나 — BE-08 (BE-07 선행) | 추천 알고리즘 설계 자체가 필요한 큰 작업 | +| 운동 목표 달성현황 | 회색(계획) | Could | ⬜ | 나+팀원(FE) — BE-06 | 목표 엔티티·API 신설(나) + 화면(팀원) | + +추가(원본 PPT엔 없으나 구현되어 있는 항목): 일일 메모/기분 기록(`DailyLog`, ✅), TTS 속도·on/off 설정(`/preferences/tts`, ✅). + +### 5-4. 관리자 페이지 + +| 기능명 | 발표 시점 표시 | 우선순위 | 현재 상태 | 담당(남은 작업) | 비고 | +|---|---|---|---|---|---| +| 관리자 대시보드 | 회색(계획) | Could | 🟡 | 나+팀원(FE) — BE-05 | 통계 API 신설(나) + 화면(팀원). 현재는 임계값 조정 API만 존재 | +| 카테고리 관리 | 회색(계획) | Could | 🟡 | 나+팀원(FE) — BE-04 | CRUD API 신설(나) + 화면(팀원) | +| 운동 영상 관리 | 회색(계획) | Could | 🟡 | 나+팀원(FE) — 미배정 ID | 등록 API는 있음(나), 조회/삭제/재등록(나)·관리 화면(팀원)은 미배정 | + +### 5-5. 추가 갭 (원본 PPT엔 없으나 운영상 필요, 코드 리뷰로 발견) + +| 요구사항 | 우선순위 | 현재 상태 | 담당(남은 작업) | 비고 | +|---|---|---|---|---| +| 개별 세션 삭제 | Should | ⬜ | 나 | 회원 탈퇴 시 전체 삭제 경로만 존재, 백엔드 API 신설 필요 | +| 리포트 생성 멱등성 (세션당 1건 보장) | Should | ⬜ | 나 | `reports.session_id` 유니크 제약 추가 필요 | +| 오래된 pose_data 파티션 자동 정리(TTL) | Could | ⬜ | 나 | 파티션 스키마는 있음, DROP 스케줄러 신설 필요 | + +### 5-6. 나의 To-do 요약 (Backend/Spring 담당만) + +위 표에서 **나(Backend)**가 단독 또는 일부 책임지는 항목만 모음 — 우선순위·`21-task-assignment.md` ID 기준: + +| 우선순위 | 항목 | 작업 ID | +|---|---|---| +| Must | 운동 worst 구간 선정 로직 보강 (precompute 방향 검토) | BE-02 | +| Should | 개별 세션 삭제 API | (미배정, 신규) | +| Should | 리포트 생성 멱등성 (`reports.session_id` 유니크) | (미배정, 신규) | +| Could | AI 리포트 자동 생성 (`GptFeedbackService`) | BE-03 | +| Could | 관리자 카테고리 관리 CRUD | BE-04 | +| Could | 관리자 대시보드 통계 API | BE-05 | +| Could | pose_data TTL 파티션 정리 스케줄러 | (미배정, 신규) | +| 2학기 | 운동 목표 엔티티·API | BE-06 | +| 2학기 | 사용자 운동 패턴 분석 API | BE-07 | +| 2학기 | 개인화 루틴 추천 API | BE-08 | +| 보류 | 운동 세트 개념 도입 (DB) | BE-09 (AI-03과 협의) | + +프론트/AI 팀원 담당 항목(개인화 TTS 재생, 관절 오버레이, 결과 화면, 세트 자동 구분 분석기 등)은 이 To-do에서 제외 — 각각 [`tasks/21-task-assignment.md`] FE-*/AI-* 참고. + +## 6. 성공 지표 + +이 프로젝트는 상용 서비스가 아닌 졸업작품 겸 포트폴리오이므로 지표는 정성 위주: + +- **시연 성공 기준**: 사용자가 카메라를 켜고 스쿼트 5회 수행 → 실시간 자세 분석 및 음성 안내 → 종료 후 통계·리포트 정상 표시, 이 한 사이클이 오류 없이 완결된다. +- **신뢰성**: 세션이 어떤 경로(정상 종료/AI 장애/네트워크 끊김)로도 IN_PROGRESS로 영구 고착되지 않는다 (타임아웃 스케줄러로 보장). +- **일관성**: 동일 세션에 대해 FastAPI 콜백과 타임아웃 스케줄러가 동시에 상태를 바꾸려 해도 데이터 정합성이 깨지지 않는다 (`@Version` 낙관락). +- 정량적 성능 지표(RPS, p99 등)는 별도 로드테스트 문서([`decisions/load-test-strategy.md`])에서 다루며, 이 문서 범위 밖. + +## 7. 제약사항 / 리스크 + +| 항목 | 내용 | +|---|---| +| 인력 | AI 서버(Python/FastAPI)는 팀원 담당 — 본인은 변경 최소화 방침 ([`feedback_minimize_python_changes`]) | +| 일정 | 1학기 MVP → 방학(포폴 심화) → 2학기 10주(기능 확장+발표), 2학기 여유 시간 부족 ([`project_two_semester_schedule`]) | +| 테스트 커버리지 | 테스트 파일 5개뿐 — 검증 신뢰도 낮음 | +| 외부 통합 다양성 | OAuth2·S3·결제·푸시·검색 등 0건 — 신입 포폴 기준 다양성 부족은 인지된 약점 | +| 장애 보호 비대칭 | Spring→AI 방향은 서킷브레이커/재시도 존재, AI→Spring 콜백 방향은 fire-and-forget (의도적 스코프 제외) | +| 부하테스트 환경 | 로컬 2코어 박스에 MySQL+백엔드+부하생성기 동거 — 절대 수치(RPS 등)는 신뢰 불가, 상대 비교만 유효 ([`project_loadtest_env_constraint`]) | + +## 8. 범위 밖 (Out of scope, 확정) + +- 스쿼트 외 운동 종목 확장 — 2학기 +- 베타 테스트/실사용자 피드백 수집 — 2학기 +- 다국어 지원 +- 소셜/랭킹/결제 기능 +- 온디바이스 AI, 갠플 마이그레이션 — 현 서버 AI 구조 유지 ([`project_keep_server_ai_architecture`]) + +--- + +## 관련 문서 +- 캡스톤 발표자료 "요구사항 정의" (HO-PT 6쪽) — 원본 요구사항 분류·완료표시의 1차 소스 (바탕화면 `남은거.PNG`, 저장소 밖 개인 파일) +- [`REQUIREMENTS.md`](./REQUIREMENTS.md) — 코드 기준 도메인별 요구사항 +- [`USE-CASES.md`](./USE-CASES.md) — 유스케이스 상세 +- [`tasks/20-feature-roadmap.md`](./tasks/20-feature-roadmap.md) — 요구사항↔코드 매핑, 스택별 잔여 작업 +- [`tasks/27-implementation-gaps.md`](./tasks/27-implementation-gaps.md) — 최신 미구현/미결정 항목 (2026-07-20 기준) +- [`12-persona-difficulty.md`](./12-persona-difficulty.md) — 페르소나 정의 diff --git a/docs/USE-CASES.md b/docs/USE-CASES.md new file mode 100644 index 0000000..498f5b6 --- /dev/null +++ b/docs/USE-CASES.md @@ -0,0 +1,154 @@ +# ShadowFit 유스케이스 + +작성: 2026-07-22 +상태: 초안 — 기존 코드/문서와 캡스톤 발표자료 원본 "요구사항 정의"(HO-PT 6쪽, 바탕화면 `남은거.PNG`) 기준 역작성. [`PRD.md`](./PRD.md)와 짝을 이루는 문서. + +--- + +## 1. 액터 + +| 액터 | 유형 | 설명 | +|---|---|---| +| 사용자 (Member) | 사람 | 앱을 사용해 운동하는 일반 회원 | +| 관리자 (Admin) | 사람 | `ROLE_ADMIN`, 운동별 싱크로율 임계값 등을 관리 | +| AI 서버 (FastAPI) | 시스템 | 포즈 추정·싱크로율 계산·영상 분석 담당. Spring과 gRPC + 내부 REST로 통신 | +| 세션 타임아웃 스케줄러 | 시스템 | Spring 내부 `@Scheduled`, 1분 주기로 방치된 세션을 정리 | + +--- + +## 2. 유스케이스 목록 + +**담당** 열은 이미 완료된 유스케이스는 표기하지 않고(끝난 일), 남은 작업이 있는 것만 3자 분업([`docs/handoff/3way-meeting-agenda.md`]) 기준으로 표시 — **나** = Backend/Spring, **팀원(FE)** = React Native, **팀원(AI)** = FastAPI. 근거: `tasks/21-task-assignment.md`. + +| ID | 이름 | 주 액터 | 우선순위 | 담당(남은 작업) | +|---|---|---|---|---| +| UC-01 | 회원가입 | 사용자 | Must | — (완료) | +| UC-02 | 로그인 / 로그아웃 | 사용자 | Must | — (완료) | +| UC-03 | 온보딩 정보 설정 | 사용자 | Must | — (완료) | +| UC-04 | 기준 동작 영상 등록 | 관리자/사용자 | Must | — (완료) | +| UC-05 | 운동 세션 시작~종료 | 사용자, AI 서버 | Must (핵심) | — (백엔드·AI 완료, 프론트 연동 검증 필요 → 팀원(FE)) | +| UC-05E | 세션 타임아웃 처리 | 스케줄러 | Must (예외) | — (완료) | +| UC-06 | 자세 피드백 발화 로그 저장 | AI 서버 | Must | — (완료) | +| UC-07 | 세션 상세 리포트 조회 | 사용자 | Must | 나 — BE-02 (worst 구간 precompute 보강) | +| UC-08 | 캘린더 / 주간 요약 조회 | 사용자 | Must | 팀원(FE) — 화면 데이터 연동(FE-09/10), API는 완료 | +| UC-09 | 일일 메모 작성 | 사용자 | Should | — (완료) | +| UC-10 | TTS 설정 변경 | 사용자 | Should | — (완료) | +| UC-11 | 싱크로율 임계값 변경 | 관리자 | Should | — (완료) | +| UC-12 | 회원 탈퇴 | 사용자 | Must | 나 — 개별 세션 삭제 API 미구현분 | +| UC-13 | 운동 타이머 표시 | 사용자 | Should (미구현 확인 필요) | 팀원(FE) — FE-12 | +| UC-14 | 관리자 카테고리 관리 | 관리자 | Could (미구현) | 나+팀원(FE) — BE-04 | +| UC-15 | 관리자 운동 영상 관리 | 관리자 | Could (미구현) | 나+팀원(FE) — 미배정 | +| UC-16 | AI 리포트 자동 생성 | 사용자, (LLM) | Could (미구현) | 나 — BE-03 | +| UC-17 | 운동 목표 설정/달성 현황 확인 | 사용자 | Could (미구현) | 나+팀원(FE) — BE-06 | +| UC-18 | 데이터 기반 개인화 루틴 추천 | 사용자 | 2학기 (미구현) | 나 — BE-08 | +| UC-19 | 사용자 운동 패턴 분석 | 사용자 | 2학기 (미구현) | 나 — BE-07 | +| UC-20 | 운동 세트 자동 구분 | 사용자 | 보류 (미구현) | 나+팀원(AI) — BE-09+AI-03 | + +--- + +## 3. 핵심 유스케이스 상세 + +### UC-01. 회원가입 + +- **액터**: 사용자 +- **사전조건**: 없음 +- **기본 흐름** + 1. 사용자가 이메일/비밀번호(+기본 정보)를 입력한다. + 2. 앱이 `POST /member/signup`을 호출한다. + 3. 서버가 이메일 중복을 확인하고 `Member`를 생성한다. + 4. 가입 완료 응답을 반환한다. +- **대안/예외 흐름** + - 3a. 이메일이 이미 존재 → 409/400 계열 오류 반환, 가입 거부 +- **사후조건**: `Member` 레코드 생성, `onboardingCompleted = false` + +### UC-02. 로그인 / 로그아웃 + +- **액터**: 사용자 +- **사전조건**: 회원가입 완료 +- **기본 흐름** + 1. 사용자가 이메일/비밀번호로 `POST /member/login` 호출. + 2. 서버가 인증 후 JWT(액세스+리프레시)를 발급한다 (`LoginResponseDto`). + 3. 이후 요청은 `JwtAuthFilter`가 토큰을 검증한다. + 4. 로그아웃 시 `POST /member/logout` → 토큰을 `JwtBlacklist`에 등록해 무효화. +- **대안/예외 흐름** + - 1a. 비밀번호 불일치 → 인증 실패 응답 + - 3a. 만료/블랙리스트 토큰으로 요청 → `CustomAuthenticationEntryPoint`가 401 반환 + - 3b. 인가 안 된 리소스 접근 → `CustomAccessDeniedHandler`가 403 반환 +- **사후조건**: 로그인 성공 시 클라이언트가 유효한 JWT 보유, 로그아웃 시 기존 토큰 무효 + +### UC-05. 운동 세션 시작 ~ 종료 (핵심 유스케이스) + +- **액터**: 사용자, AI 서버 (FastAPI) +- **사전조건**: 로그인 완료, 온보딩 완료, 대상 운동에 대한 `ExerciseReference`(기준 좌표) 존재 +- **기본 흐름** + 1. 사용자가 운동 화면에서 시작 버튼을 누른다. + 2. 앱 → Spring `POST /exercises/sessions` 호출. + 3. Spring이 `Session`(status=`IN_PROGRESS`)을 생성하고, gRPC로 AI 서버에 분석 시작을 알린 뒤 **즉시 `sessionId`를 반환**한다 (AI 분석 완료를 기다리지 않음). + 4. 사용자가 카메라 앞에서 운동을 수행하는 동안 AI 서버가 rep 단위로 관절 좌표·싱크로율·자세 오류를 계산한다. + 5. AI 서버가 rep 완성 시마다 포즈 데이터를 배치로 `POST /internal/exercises/pose-data`(`X-Internal-Token` 인증)로 Spring에 저장한다. + 6. 사용자가 종료 버튼을 누르면 앱 → Spring `PUT /exercises/sessions/{sessionId}/complete` 호출, `totalReps` 등 결과 전달. + 7. Spring이 `Session.status = COMPLETED`로 갱신한다. +- **대안/예외 흐름** + - 3a. AI 서버가 gRPC 응답 불가(다운) → Resilience4j 서킷브레이커/재시도 동작 (Spring→AI 방향만 보호됨) + - 4a→7 사이 사용자가 앱을 종료/네트워크 단절 등으로 종료 요청을 못 보냄 → **UC-05E(세션 타임아웃)**로 이어짐 + - 6a. 이미 타임아웃 스케줄러가 `FAILED` 처리한 세션에 뒤늦게 완료 요청이 온 경우 → `@Version` 낙관락으로 **FastAPI(정상 완료) 결과를 우선** 반영, 타임아웃 처리보다 실제 완료가 신뢰됨 +- **사후조건**: `Session.status ∈ {COMPLETED, FAILED}`, `pose_data`에 세션 전체 rep 기록 존재 + +### UC-05E. 세션 타임아웃 처리 (예외 흐름의 독립 유스케이스) + +- **액터**: 세션 타임아웃 스케줄러 +- **트리거**: 1분 주기 스케줄 실행 +- **기본 흐름** + 1. 스케줄러가 `status = IN_PROGRESS`인 세션 중 `시작시간 + 예상시간 + 30분 버퍼`를 초과한 세션을 조회한다. + 2. 해당 세션들을 `FAILED`로 갱신한다. +- **동시성 규칙**: AI 서버의 정상 완료 콜백과 스케줄러의 타임아웃 처리가 같은 세션을 동시에 갱신하려 할 경우, `Session`의 `@Version` 낙관락으로 충돌을 감지하고 **AI 서버발 완료 결과가 우선**한다 (사용자가 실제로 운동을 끝냈다면 그 결과가 진실에 더 가까우므로). +- **사후조건**: 방치된 세션이 영구 `IN_PROGRESS`로 남지 않음 + +### UC-07. 세션 상세 리포트 조회 + +- **액터**: 사용자 +- **사전조건**: 완료된 세션(`COMPLETED`) 존재 +- **기본 흐름** + 1. 사용자가 세션 상세 화면 진입 → `GET /reports/session/{sessionId}` 호출. + 2. 서버가 해당 세션의 `pose_data`를 조회해 worst 구간, 파트별 점수, 이전 세션 대비 비교를 계산한다. + 3. `SessionReportResponseDto`를 반환한다. +- **현재 알려진 한계**: worst 구간 등은 **조회 시점에 즉석 재계산**된다 (`ReportService.getSessionReport`). 세션 종료 시 미리 계산해두는 precompute-on-write 구조는 아직 없음 — 조회량이 늘면 매번 재계산 비용이 발생하는 구조적 갭으로 인지되어 있음. +- **사후조건**: 없음 (읽기 전용) + +--- + +## 4. 그 외 유스케이스 (요약) + +| ID | 기본 흐름 요약 | 예외/비고 | +|---|---|---| +| UC-03 온보딩 정보 설정 | `PATCH /member/onboarding/{email}`로 페르소나·키/몸무게·선호영상 단계별 저장, `YoutubeValidator`로 URL 검증 | 잘못된 유튜브 URL → 검증 실패 응답 | +| UC-04 기준 동작 영상 등록 | `POST /exercises/{exerciseId}/reference?youtubeUrl=` → YouTube 다운로드 → MediaPipe로 관절 좌표 추출 → `ExerciseReference` 저장 (운동당 1회성 사전 작업) | 실패 시 해당 운동은 세션 시작 불가 (기준 데이터 없음) | +| UC-06 자세 피드백 발화 로그 저장 | 세션 종료/세트 경계 시점에 AI 서버가 gRPC `ExerciseService.ReportFeedbackBatch`로 배치 전송 → `SessionFeedbackLog` 저장 | 실시간 매 rep 호출 금지(부하 분리 설계), REST 배치 경로는 2026-05-26 gRPC로 통일되며 폐기 | +| UC-08 캘린더/주간 요약 조회 | `GET /reports/calendar?year&month`, `GET /reports/weekly-summary` | 읽기 전용 | +| UC-09 일일 메모 작성 | `POST /reports/daily-logs`로 메모/기분(`Mood`) upsert | 같은 날짜 재작성 시 덮어쓰기(upsert) | +| UC-10 TTS 설정 변경 | `PATCH /preferences/tts`로 on/off, 속도(0.5~2.0) 변경 | 범위 밖 값 → 검증 실패 | +| UC-11 싱크로율 임계값 변경 | 관리자가 `PATCH /admin/exercises/{exerciseId}/thresholds` 호출 | `beginner < advanced` 제약 위반 시 거부, `ROLE_ADMIN` 아니면 403 | +| UC-12 회원 탈퇴 | `DELETE /member/{email}` → 회원 및 연관 데이터 전체 삭제 | 개별 세션만 선택 삭제하는 기능은 없음(전체 삭제 경로만 존재) — 알려진 갭 | + +--- + +## 5. 계획 단계 유스케이스 (원본 요구사항 정의 기준, 현재 미구현/부분구현) + +원본 PPT(HO-PT 6쪽)에는 있으나 아직 코드로 실현되지 않았거나 검증이 안 된 유스케이스. 정식 사전/기본/예외 흐름 템플릿은 실제 구현 시점에 채운다 — 지금은 "무엇을 해야 하는지"와 "왜 아직인지"만 정리. + +| ID | 이름 | 사전조건(예정) | 기본 흐름 요약(예정) | 현재 막힌 지점 | +|---|---|---|---|---| +| UC-13 | 운동 타이머 표시 | 세션 진행 중 | 세션 시작 시각부터 경과 시간을 화면에 표시 | 백엔드에 `start_time`/`end_time`은 있으나 프론트 타이머 UI 존재 여부 미확인 | +| UC-14 | 관리자 카테고리 관리 | 관리자 로그인 | 운동 카테고리 CRUD | `ExerciseCategory` enum만 존재, 관리 API·화면 없음 | +| UC-15 | 관리자 운동 영상 관리 | 관리자 로그인, 기준 영상 등록됨(UC-04) | 등록된 기준 영상 조회/삭제/재등록 | 등록(UC-04) 외 조회·삭제·재등록 API 없음 | +| UC-16 | AI 리포트 자동 생성 | 세션 완료(UC-05) | 세션 데이터를 LLM에 요약 요청 → 자연어 리포트 생성 | `GptFeedbackService` 등 호출 코드 자체가 없음, `OPENAI_API_KEY` 환경변수만 준비됨 | +| UC-17 | 운동 목표 설정/달성 현황 확인 | 로그인 | 사용자가 목표(횟수·기간 등) 설정 → 누적 데이터와 비교해 달성률 표시 | 목표 엔티티/API가 아예 없음 | +| UC-18 | 데이터 기반 개인화 루틴 추천 | 세션 기록 누적 | 과거 세션 데이터 분석 → 다음 루틴 추천 | 추천 알고리즘 설계부터 필요한 큰 작업, 2학기 범위 | +| UC-19 | 사용자 운동 패턴 분석 | 세션 기록 누적 | 사용자별 운동 습관/추세 분석 및 표시 | 분석 로직 자체가 없음, 2학기 범위 | +| UC-20 | 운동 세트 자동 구분 | 세션 진행 중 | rep을 세트 단위로 자동 그룹핑 | DB 스키마에 set 개념이 없음, AI 서버 분석기도 세트 미지원. [`project_squat_first`] 방침상 다른 운동 확장 시점과 함께 보류 | + +## 관련 문서 +- [`PRD.md`](./PRD.md) — 목표/범위/우선순위 +- [`REQUIREMENTS.md`](./REQUIREMENTS.md) — API 레벨 요구사항 원본 +- [`tasks/27-implementation-gaps.md`](./tasks/27-implementation-gaps.md) — 유스케이스 상 "알려진 한계"의 근거 +- [`decisions/session-lifecycle-checklist.md`](./decisions/session-lifecycle-checklist.md) — UC-05/UC-05E 동시성 설계 근거 diff --git a/docs/handoff/ai-persona-threshold-connection.md b/docs/handoff/ai-persona-threshold-connection.md new file mode 100644 index 0000000..24db952 --- /dev/null +++ b/docs/handoff/ai-persona-threshold-connection.md @@ -0,0 +1,97 @@ +# AI 서버 변경 기록 — 페르소나별 싱크로율 임계값 연결 + +작성: 2026-07-22 +대상: **AI(FastAPI) 담당자** — 본인(Backend) + Claude가 [`feedback_minimize_python_changes`] 완화 방침(2026-07-22)에 따라 AI 서버까지 직접 수정. 팀원은 이 문서로 변경 내용만 확인. +배경: 페르소나별 싱크로율 기준(헬린이 60%/헬창 85%/다이어트 70%/재활 50%)이 `12-persona-difficulty.md`에는 있었지만, 실제로는 **Spring DB 컬럼 2개(beginner/advanced만), AI서버 `constants.py`의 죽은 dict(4개, 0~1 스케일)로 따로 놀고 있었고, 세션 판정에 쓰이는 실제 로직은 페르소나 무관 고정값(`sync_rate >= 70/40`)** 이었음. 이번 작업으로 실제로 연결. + +--- + +## 0. 요약 + +| 항목 | 내용 | +|---|---| +| 무엇을 연결했나 | Member의 `selectedPersona` → gRPC `AnalyzeRequest.persona` → AI서버 `SessionState.persona` → rep 판정 시 페르소나별 임계값 적용 | +| Spring 변경 | 스키마 컬럼 2개 추가, 임계값 admin API 4종 지원, gRPC 세션 시작 요청에 persona 필드 추가 | +| AI서버 변경 | proto 필드 추가, pb2 재생성, 세션 상태에 persona 저장, 하드코딩 70/40 제거 → 페르소나 기반 계산 | +| AI서버 코드 변경량 | 약 20줄 (proto 1줄, session_state 2곳, servicer 3줄, constants 스케일 수정, squat_analyzer 판정 로직) | +| 테스트 | Spring: `compileJava`/`compileTestJava` clean. AI서버: 관련 모듈 import 확인 + 기존 `tests/test_squat_analyzer.py` 영향 없음(해당 테스트가 이 경로를 안 건드림, pytest 미설치라 직접 실행은 못 함) | + +--- + +## 1. AI 서버(`ai-server/`) 변경 파일 + +### `app/proto/exercise.proto` (텍스트 소스) +`AnalyzeRequest`에 필드 추가: +```proto +message AnalyzeRequest { + int64 exercise_id = 1; + int64 session_id = 3; + string reference_source = 2; + repeated PoseDataRequest reference_poses = 4; + string persona = 5; // 추가. 빈 문자열이면 AI측에서 BEGINNER로 취급 +} +``` +`backend/src/main/proto/exercise.proto`(Spring 쪽 사본)에도 동일하게 반영 — 두 `.proto` 텍스트는 지금도 수동 동기화 상태(분기 B, 단일 소스화 미결)라 양쪽 다 고침. + +### `exercise_pb2.py` / `exercise_pb2_grpc.py` (ai-server **루트**, 재생성) +`app/grpc/exercise_servicer.py`가 `import exercise_pb2`로 **루트의 pb2를 참조**하고 있어(아래 "발견했지만 안 고친 것" 참고) 이 위치의 pb2만 재생성함. `grpc_tools.protoc -I app/proto --python_out=. --grpc_python_out=. app/proto/exercise.proto`로 생성. `exercise_pb2_grpc.py`는 diff 없음(메시지 필드만 추가, RPC는 안 바뀜). + +### `app/grpc/session_state.py` +`SessionState` dataclass에 `persona: str = "BEGINNER"` 필드 추가, `SessionStateRegistry.create()`가 `persona` 파라미터를 받아 넘기도록 수정. + +### `app/grpc/exercise_servicer.py` +`StartAnalysis`에서 `request.persona`(없으면 `"BEGINNER"`)를 읽어 `get_registry().create(...)`에 전달. + +### `app/utils/constants.py` +```python +# 변경 전 (0~1 스케일 — 어디서도 참조 안 되던 죽은 dict) +SYNC_THRESHOLDS = {"BEGINNER": 0.60, "ADVANCED": 0.85, "DIET": 0.70, "REHAB": 0.50} +# 변경 후 (sync_rate와 동일한 0~100 스케일) +SYNC_THRESHOLDS = {"BEGINNER": 60.0, "ADVANCED": 85.0, "DIET": 70.0, "REHAB": 50.0} +``` + +### `app/core/squat_analyzer.py` +`_summarize_rep`의 하드코딩 판정을 페르소나 기반으로 교체: +```python +# 변경 전 +if sync_rate >= 70: + msg = "자세 양호" +elif sync_rate >= 40: + msg = "자세 보정 필요" +else: + msg = "즉시 자세 수정 필요" + +# 변경 후 +pass_threshold = SYNC_THRESHOLDS.get(state.persona, SYNC_THRESHOLDS["BEGINNER"]) +low_threshold = pass_threshold * _LOW_CUT_RATIO # = 40/70, 기존 두 컷의 비율 유지 +if sync_rate >= pass_threshold: + msg = "자세 양호" +elif sync_rate >= low_threshold: + msg = "자세 보정 필요" +else: + msg = "즉시 자세 수정 필요" +``` +"즉시 수정" 컷을 페르소나 무관 고정값으로 둘지, 지금처럼 비례 스케일할지는 판단이 필요한 지점이었음 — 기존 비율(40/70 ≈ 0.57) 유지 쪽으로 확정(2026-07-22 대화에서 결정). + +--- + +## 2. Spring 쪽 변경 (참고용 요약) + +- `mysql/schema.sql`: `exercises`에 `sync_threshold_diet`, `sync_threshold_rehab` 컬럼 추가 (기존 beginner/advanced와 같은 패턴) +- `Exercise.java`, `ExerciseThresholdResponseDto`, `ThresholdUpdateDto`, `AdminExerciseService`: 4개 페르소나 임계값 admin API로 확장 (beginner < advanced 검증은 유지, diet/rehab은 축이 달라 순서 제약 없음) +- `ExerciseAnalysisService.sendAnalysisRequestToFastApi`: `member.getSelectedPersona().name()`을 gRPC `AnalyzeRequest.persona`로 전달 + +--- + +## 3. 발견했지만 이번에 안 고친 것 + +- **`ai-server/app/proto/exercise_pb2.py`·`exercise_pb2_grpc.py`가 stale.** `.proto` 텍스트는 최신인데 거기서 생성된 pb2는 예전 버전(`GetFinalPoseData`/`SessionRequest`/`PoseDataList` 등 지금 proto엔 없는 것들 포함, `StopAnalysis`/`ReportFeedbackBatch` 없음)이라 사실상 죽은 파일. 실제로 쓰이는 건 ai-server **루트**의 `exercise_pb2.py`(`import exercise_pb2`로 참조됨). 분기 B(proto 단일 소스화 미결)와 얽힌 별도 정리 작업 필요. +- **`POST /sync` REST 엔드포인트(`app/api/endpoints/sync.py` → `dtw_calculator.classify_sync_visual_cue`)는 여전히 고정 70/40.** 이건 세션 기반 gRPC 흐름과 무관한 별도 stateless 엔드포인트(persona/session 컨텍스트 자체가 없음)라 이번 스코프에 안 넣음. 온보딩 가이드용으로만 쓰이는 듯— 필요하면 별도 논의. +- `mysql/data.sql`이 `schema.sql`과 별도로 자체 `CREATE TABLE`을 갖고 있어 컨테이너 init 순서상 `schema.sql`을 덮어쓸 수 있는 문제 발견 — 이번 작업과 별개로 처리하기로 함(사용자 확인, 2026-07-22). + +--- + +## 관련 문서 +- [`PRD.md`](../PRD.md) §5-2 — 페르소나 임계값 요구사항 상태 +- [`12-persona-difficulty.md`](../12-persona-difficulty.md) — 페르소나 정의·기준값 원본 +- [`decisions/ai-backend-coupling.md`](../decisions/ai-backend-coupling.md) — Spring↔AI 결합 관련 기존 분기들 diff --git a/docs/portfolio/db-deep-dive.md b/docs/portfolio/db-deep-dive.md index eb5be23..dce2b2b 100644 --- a/docs/portfolio/db-deep-dive.md +++ b/docs/portfolio/db-deep-dive.md @@ -96,6 +96,7 @@ - **결과** (공정 측정, 워밍업 통제, [`load-test-strategy §7.6`](../decisions/load-test-strategy.md)): - throughput **23.5 → 46.7 RPS (+99%)**, p50 −64%, p99 7,549→4,784ms (**−37%**) - 면접: "왜 config(`hibernate.jdbc.batch_size`)로 안 풀고 JdbcTemplate? → IDENTITY라 Hibernate batch 미발동, 드라이버 레벨 batch가 정석." +- **면접(꼬리질문 대비)**: "왜 ID 전략을 SEQUENCE로 안 바꿨나? → SEQUENCE는 INSERT 전에 미리 ID를 확보할 수 있어 이론상 Hibernate batch가 가능하지만, MySQL엔 네이티브 SEQUENCE가 없어 별도 ID발급 테이블로 흉내내야 함(추가 오버헤드) + 엔티티 전체의 PK 생성 전략을 바꾸는 더 큰 변경. `JdbcTemplate.batchUpdate`는 이 저장 경로 하나만 국소적으로 우회해 같은 효과를 더 작은 변경으로 냄." ### B. 읽기 최적화 — projection / 캐싱 / precompute ⬜ 헤드라인 본편 @@ -118,16 +119,18 @@ 상세 스토리는 [`problem-solving-log.md #3·#4`](./problem-solving-log.md). -### D. 시계열 보존 — pose_data 버퍼의 TTL ⬜ 설계+트리거 +### D. 시계열 보존 — pose_data 버퍼의 TTL ✅ 파티션 스키마 구현 / ⬜ 자동 만료 트리거 미착수 > §0 재설계의 하위 도구. pose_data를 **단기 버퍼**로 만드는 만료 메커니즘. -- **pose_data는 중간 산출물(버퍼)** — precompute(§0)로 worst 구간을 `reports`에 옮긴 직후 cold → TTL 안전(UX 손실 0). precompute가 TTL을 *안전하게* 만든다(상호 강화). -- **구현은 DELETE 아니라 DROP PARTITION**: 날짜 Range 파티셔닝 → 가장 오래된 파티션을 **O(1) 메타데이터 연산**으로 제거(락 거의 없음). 대량 DELETE는 락·undo 폭발. → **실측 확인**(1억 행 rig, [`realmysql-experiments §②(d)`](./realmysql-experiments.md)): 같은 ~8M행 만료가 **DELETE 18.6분(빈 952MB 파일 잔존) vs DROP PARTITION 1.8초(파일째 회수) ≈ 625x**. +- ⚠️ **현재 실제 상태(2026-07-20, PR #43 반영 후)**: `mysql/schema.sql`에 `pose_data` **Range 파티션 스키마 자체는 실제로 반영됨**(월별 14개 파티션+`pfuture`, PK `(id, created_at)`). 근데 **오래된 파티션을 주기적으로 `DROP PARTITION`하는 자동화(스케줄러)는 코드에 없음** — 지금은 파티션 구조만 있고 실제 만료 자동화는 미착수. 아래 내용은 그 만료 메커니즘이 붙었을 때의 **설계 의도**이지, 지금 자동으로 실행 중인 게 아님. +- **pose_data는 중간 산출물(버퍼)이어야 함** — precompute(§0, 아래 참고)로 worst 구간을 `reports`에 옮긴 직후 cold → TTL 안전(UX 손실 0)해진다는 게 설계 의도. **단, precompute-on-write 자체도 아직 미구현**(§B-3 참고, `ReportService.getSessionReport`는 조회 시마다 `pose_data`를 즉석 재계산 — TTL을 지금 걸면 재계산할 원본이 없어질 위험이 있어 precompute 구현이 선행돼야 함). +- **구현 방향은 DELETE 아니라 DROP PARTITION**: 날짜 Range 파티셔닝 → 가장 오래된 파티션을 **O(1) 메타데이터 연산**으로 제거(락 거의 없음). 대량 DELETE는 락·undo 폭발. → **실측 확인**(1억 행 rig, [`realmysql-experiments §②(d)`](./realmysql-experiments.md)): 같은 ~8M행 만료가 **DELETE 18.6분(빈 952MB 파일 잔존) vs DROP PARTITION 1.8초(파일째 회수) ≈ 625x**. - **파티셔닝의 진짜 가치 = 쿼리 pruning 아니라 "값싼 TTL"** (쿼리는 이미 인덱스로 빠름, §4.3). 이 reframe이 핵심. -- ⚠️ **파티션 전제 = PK에 파티션 키 포함** — pose_data PK `id`만으론 created_at 파티션 불가, PK를 `(id, created_at)`로 바꿔야(실험 시 처리). +- ⚠️ **파티션 전제 = PK에 파티션 키 포함** — pose_data PK `id`만으론 created_at 파티션 불가, PK를 `(id, created_at)`로 변경 완료(위 참고). - **샤딩은 안 함** — 단일 MySQL로 충분. 스케일 시에도 샤딩 전에 raw를 S3 티어링이 먼저(§0). -- (선택) S3 아카이빙 → DROP. FK `session→pose_data` ON DELETE CASCADE. +- (선택) S3 아카이빙 → DROP. +- ⚠️ **FK는 이미 제거됨**: `session→pose_data ON DELETE CASCADE`는 파티셔닝과 호환 안 돼(`ERROR 1506`) 제거됐고, 회원 탈퇴 시 정리는 애플리케이션 레벨 비동기 트리거(B5, `PoseDataCleanupService`)로 대체됨 — 상세 트레이드오프는 [`pose-data-partition-fk-tradeoff.md`](../decisions/pose-data-partition-fk-tradeoff.md). - **트리거 명시**: DAU 50엔 불필요. 버퍼 보존기간×볼륨이 커져 DELETE 만료가 부담될 때 발동. - 포폴 가치: 보존정책 + partition-drop은 신입이 거의 안 함 (§4.7). diff --git a/docs/portfolio/realmysql-experiments.md b/docs/portfolio/realmysql-experiments.md index 7121fa5..21386b1 100644 --- a/docs/portfolio/realmysql-experiments.md +++ b/docs/portfolio/realmysql-experiments.md @@ -84,12 +84,16 @@ - 인덱스 사용: `type=ref`, `key=idx_session_timestamp`, **`Extra=NULL` (filesort 없음)** — WHERE+ORDER BY를 인덱스가 완결. - `IGNORE INDEX` 강제 풀스캔: **4.13M 행 스캔 + filesort = 85,000ms (85초)**. - → **"인덱스 추가"가 아니라 "이미 최적임을 측정으로 발견"**(§4.3 확인). 인덱스가 85초→lookup으로 바꾸는 핵심 역할도 대조로 입증. -- **면접**: "인덱스 넣기 전에 EXPLAIN으로 이미 최적 확인(filesort 0). 없으면 4M 풀스캔 85초. 진짜 병목은 payload(Ch.11 projection)." + - **✅ 인덱스 vs 무인덱스 직접 대조 재측정(2026-07-20)**: 위 85초는 풀스캔 단독 비용이라 "인덱스 있을 때 대비 몇 배냐"가 별도로 명시된 적 없었음(과거엔 이 배수를 다른 실험 수치와 짜깁기해 "7,000배"로 추정했었는데, 이는 근거 없는 추론이라 폐기). 실제로 같은 테이블·같은 조건에서 인덱스 있음 vs `IGNORE INDEX` 강제 풀스캔을 직접 재측정(`SET profiling`, wall-clock 오염 배제): + - 더미 JSON(412.5만 행): 인덱스 있음 **~1.76ms** vs 강제 풀스캔 **~1.17초** → **약 660배**. + - 실제 2.1KB JSON(412.5만 행, 원 실험과 동일 조건): 인덱스 있음 **~3.0ms** vs 강제 풀스캔 **~27.1초** → **약 9,000배**. + - **더미 JSON은 이 배수를 과소평가함** — 실제 JSON은 행이 커서 페이지당 담기는 행 수가 줄어 풀스캔이 훑을 페이지 수 자체가 늘어나는 반면(인덱스 조회는 750행만 보므로 거의 영향 없음, 1.76ms→3.0ms), 그 결과 배수가 660배→9,000배로 크게 벌어짐. **payload 크기가 "인덱스 유무의 체감 차이" 자체를 증폭시킨다**는 걸 확인. +- **면접**: "인덱스 넣기 전에 EXPLAIN으로 이미 최적 확인(filesort 0). 실제 조건(2.1KB JSON, 412만 행)으로 인덱스 유무를 직접 대조하니 약 9,000배 차이. 진짜 병목은 payload(Ch.11 projection)." ### ② Ch.11 + Ch.13 — 쿼리 최적화 & 파티션 🟢⭐ - **개념**: bulk INSERT, 페이지네이션(offset→cursor), projection / Range 파티션·프루닝. - **이 프로젝트 (a) 쓰기**: ✅ batch insert 완료 — JdbcTemplate `batchUpdate`, throughput **+99%**, p99 −37% (§7.6). Ch.11 bulk insert 그 자체. -- **이 프로젝트 (b) projection**: `ReportService` JSON blob 헛로드(~3MB) → 3컬럼 DTO. 🔶 +- **이 프로젝트 (b) projection**: ✅ `ReportService` JSON blob 헛로드(~3MB) → 3컬럼 DTO. - **이 프로젝트 (c) 페이지네이션**: ✅ 전체 테이블 시간순 페이지네이션 offset → keyset(cursor). 1억 행 합성 rig로 실측 — offset O(N) 선형 저하 vs keyset 평탄 입증. - **이 프로젝트 (d) 파티션**: `pose_data` 날짜 Range 파티션 → **버퍼 TTL의 DROP PARTITION**(주용도, [`db-deep-dive §2-0`](./db-deep-dive.md) raw=버퍼) + cross-session 집계 pruning(부차). ⚠️ PK를 `(id, created_at)`로 변경 선결. 샤딩은 미적용(과설계). - **언제 파티셔닝하나? — "행 수"가 아니라 "용도"** ⭐: 업계 감각치(단일 테이블이 버퍼풀 초과 / 수천만~1억 행 / 수십~100GB↑)는 **필요조건일 뿐 충분조건 아님**. RealMySQL도 "몇 행"으로 안 박고 "감당·관리(특히 오래된 데이터 삭제)가 부담일 때"로 설명. 정당화 트리거 3: ①TTL/보존(대량 DELETE 부담→DROP PARTITION) ②쿼리가 항상 파티션 키로 범위 좁힘(pruning) ③파티션 단위 백업·아카이빙. @@ -102,7 +106,9 @@ - payload **1,716.8 KB → 22.4 KB (−98.7%)**, warm 쿼리 **12.1ms → 1.5ms (8x, −87%)**. **인덱스는 동일** — 차이는 `joint_coordinates`(2.3KB JSON)가 InnoDB **off-page(overflow) 저장**이라 SELECT 시 추가 random I/O, projection이 회피(Ch.15). - ⚠️ cold 721ms → warm 12ms(같은 쿼리) — 워밍업 통제 필수 재확인(§7.6). 절대 ms는 로컬 기준, 상대 delta는 신뢰 가능. - **✅ AWS 1억 행 real-JSON 재검증(2026-07-15)**: 면접 준비 중 로컬 하드웨어 제약(255GB 문제, §3)을 실제로 AWS EC2(m6i.xlarge)+EBS 700GB로 우회해 **진짜 1억 행 × 실제 2.3KB JSON**(`pose_data_real_scale`, 133,334세션×750행 정확히 1억)으로 재현. payload **1,740.1 KB → 22.6 KB (−98.7%)** — 412만 행 때와 거의 동일(행수와 무관, 세션당 바이트 비율이라 예상대로). **warm 쿼리는 오히려 훨씬 크게 개선**: 40.6ms → 1.4ms(**약 29배**, 반복 측정 시 최대 41배까지 관찰) — 412만 행 때의 8배보다 큼. 이유: 버퍼풀(2GB)은 동일한데 테이블이 25배 커져서(~230GB) **작업셋 대비 버퍼풀 비율이 더 나빠짐** → 풀엔티티 로드의 off-page 랜덤 I/O가 캐시에 덜 걸리고 실제 디스크 I/O를 더 많이 탐 → projection이 그 I/O를 회피하는 효과가 스케일이 커질수록 더 커짐. **결론이 뒤집힌 게 아니라 강화됨**: "이미 최적 인덱스, projection이 진짜 병목 해결" 결론은 동일, 배수만 스케일에 비례해 커짐. + - ⚠️ **측정 도구 자체의 함정도 잡아냄**(§0 Performance Schema 백본 참고): 이 29~41배는 재측정 결과 — 첫 시도는 SSH+`docker exec` 왕복 wall-clock(~50ms 오버헤드)로 쟀다가 **2배로 축소돼 나왔음**. sub-100ms 쿼리 차이가 그 오버헤드에 묻힌 것. `SET profiling=1`(DB 내부 타이밍, 네트워크 왕복 배제)로 바꿔 재측정해서 진짜 29~41배를 찾음 — 첫 숫자를 그대로 안 믿고 측정 도구 자체를 의심·교정한 과정. - **✅ 풀스캔 대조도 1억 행으로 재검증(2026-07-15)**: `IGNORE INDEX` 강제 풀스캔 = **2,120.9초(35분 20초)**. 412만 행의 85초 대비 1억 행은 412만 행의 약 24.27배인데, 85×24.27≈2,063초(34.4분) 선형 추정치와 **거의 정확히 일치**(2,120.9초) — 풀스캔 비용이 행 수에 선형(O(N))으로 늘어난다는 걸 실제 1억 행 규모에서 재확인. + - ⚠️ **실무 프레임 명확화**: 이 쿼리는 사용자가 매번 누르는 조회가 아니라 **세션 종료 시 precompute가 세션당 딱 한 번 도는 것**([`db-deep-dive §2-0`](./db-deep-dive.md)). 그래서 8~40배 개선의 의미는 "사용자 체감 지연이 빨라졌다"가 아니라 — precompute는 비동기라 사용자는 이 지연을 원래 못 느낌 — **"세션마다 반복되는 이 잡의 I/O·버퍼풀 점유를 줄여 시스템 전체 자원 부담을 낮췄다"**는 쪽. "리포트가 빨라졌다"로 말하면 부정확, "정기적으로 도는 무거운 잡의 자원 소모를 줄였다"가 정직한 프레임. - **결과 (c) 페이지네이션 ✅ (2026-06-03, 1억 행 합성[측정 시점 9,750만, 결론 동일], warm 3회째, EXPLAIN ANALYZE)**: | 깊이(OFFSET) | offset ms | keyset ms | speedup | @@ -182,7 +188,7 @@ ### ④ Ch.4 + Ch.15 — 아키텍처(MVCC·버퍼풀) & JSON 🟢🟡 - **개념**: InnoDB 버퍼풀, undo, MVCC 스냅샷 / JSON 저장·함수·generated column 인덱스. - **설계 (MVCC)**: 긴 batch insert 트랜잭션 open 중 동시 리포트 조회가 **이전 스냅샷** 읽음(RR) 관찰 — gRPC 적재↔조회 사이 일관성. -- **설계 (버퍼풀)**: 1억 행 > `innodb_buffer_pool_size`면 디스크 읽기 급증 — hit율(`sys`) 관찰, 버퍼풀 사이징 영향. +- **설계 (버퍼풀)**: 1억 행 > `innodb_buffer_pool_size`면 디스크 읽기 급증 — hit율(`sys`) 관찰, 버퍼풀 사이징 영향. ⚠️ **대상은 세션 리포트 조회가 아니라 cross-session 집계**(admin 통계·패턴분석, `SELECT SUM(sync_rate) ... WHERE created_at BETWEEN`류 — `measure_bufferpool.sh`) — 개인 리포트는 precompute로 `reports` 한 행만 보므로 `pose_data` 크기와 무관하게 안 느려짐(§5 캐비엇 "대용량 쿼리 저하"와 동일 구분). - **설계 (JSON)**: `joint_coordinates` 저장 비용 측정, JSON path에 **generated column + 인덱스** vs 정규화 테이블 트레이드오프. 🟡 (현재 불필요, 데모용). - **설계 (트림 33→13)** 🔶: `_landmarks_to_json`이 MediaPipe **33개 전부 저장**(필터 없음, `constants.py LANDMARK`는 13개만 참조) → JSON 함수로 사용 13개만 추출해 `AVG(LENGTH)` before/after 측정(33→13 ≈ ~60% 절감). **단 이건 국소 미봉책** — 진짜 해법은 [`db-deep-dive §2-0`](./db-deep-dive.md)의 raw→buffer/serving 분리(raw 단기화하면 트림 영구비용 자체가 사라짐). - **결과 (트림 33→13) ✅ (2026-06-07, `_pose_template` 현실 JSON)**: @@ -200,12 +206,14 @@ - **작업셋 ≫ 풀**: 월 파티션 풀스캔(540MB ≫ 128MB) → cold/warm 모두 **매번 ~485MB 디스크 읽기**, hit ~84% 고정, ~10초. 작업셋이 풀을 초과해 **warm 재실행도 캐시 무력**(eviction 으로 재miss). - **작업셋 < 풀**: 좁은 PK 범위(수MB) → warm **디스크 0MB, hit 100%**, ~0.45초. 핫 데이터 메모리 상주. (⚠️ 이 구간 cold 도 직전 실행 잔여로 이미 resident — 진짜 cold 아님, 명시) - **⭐ read-ahead 함정**: 순차 스캔은 InnoDB 선읽기(`Innodb_buffer_pool_read_ahead`)가 페이지를 비동기로 당겨와 **표준 hit율 공식(1−`reads`/`read_requests`)이 거짓 99.9%** 가 됨(`reads`는 동기 미스만 카운트). 진짜 물리 I/O = `reads`+`read_ahead`, 또는 `Innodb_data_read`(바이트)로 봐야 84%/485MB 가 보임. **"hit율 공식은 read-ahead 가 가린다"** 가 핵심 교훈. + - **계산식 명시**: 위 84%는 naive 공식이 아니라 **corrected 공식** `hit% = (1 − (reads+read_ahead)/read_requests) × 100`(`measure_bufferpool.sh`의 `hit=(1-$phys/$drq)*100`, `phys=drd+dra`와 동일)으로 계산한 값. 이 실행의 naive 공식(`1−reads/read_requests`) 원시 카운터는 로그로 안 남겨 재구성 불가 — 같은 대조를 raw 값까지 포함해 명확히 보여주는 건 아래 AWS 재검증(95.36% naive vs 변화 없는 `data_read`)쪽 참고. - 같은 뿌리: 페이지네이션 cold/warm(②c §3)·시딩 가속(버퍼풀 128MB→2GB)·라우팅 모두 "작업셋 vs 메모리". 사이징 판단 = ⓐraw 단기화·파티션 만료로 작업셋 축소 ⓑ풀 증설. - **✅ AWS 1억 행 real-JSON 재검증(2026-07-15)**: 기존 실험은 더미 JSON(`{}`, 2바이트)이라 off-page 저장 자체가 발동 안 했을 가능성이 있어, `pose_data_real_scale`(진짜 2.3KB JSON, 버퍼풀 2GB, m6i.xlarge)에서 재현. - **작업셋 ≫ 풀(세션 70000~75000, 5,000세션, 실제 JSON 합 8.19GB)**: cold **675.2초**, warm(같은 범위 즉시 재실행) **675.85초** — **거의 동일, warm 이득 0**. `data_read` 델타도 cold 12.79GB vs warm 12.78GB로 동일 — 작업셋이 풀(2GB)의 4배가 넘어 캐시가 전혀 안 먹힘. - **read-ahead 함정 재확인**: warm 구간의 naive hit율 공식 `1-reads/read_requests` = 1-779,614/16,804,676 = **95.36%**(그럴듯해 보임) — 근데 실제 물리 I/O(`data_read`)는 cold·warm 둘 다 ~11.9GB로 **하나도 안 줄어듦**. 공식만 보면 "잘 캐시되고 있다"고 착각하기 딱 좋은 사례. - **작업셋 < 풀(세션 80000~80010, 11세션, 19.3MB)**: cold **1.557초**(reads=1740, data_read=27.2MB) → warm **0.461초**(reads=**0**, data_read=**0바이트**, 3.4배) — **완전한 캐시 히트**, 진짜 cold부터 시작한 깨끗한 대조. - **결론 강화**: "작업셋 vs 버퍼풀 비율"이 진짜 원인이라는 게 더미 데이터가 아니라 실제 off-page JSON에서도 그대로 재현됨 — 오히려 실제 JSON이 더 무거워서(off-page 랜덤 I/O) 대조가 더 극적으로 나타남(작은 쪽 3.4배 vs 큰 쪽 0배). + - ⚠️ **675.2초를 절대 성능 기준으로 일반화 금지**: 이 숫자는 "MySQL/이 시스템이 원래 이 정도 느리다"는 성능 주장이 아니라, 캐시 무력화를 보여주려고 일부러 만든 최악의 케이스(5,000세션·375만 행·8.19GB를 단일 쿼리로 통째 스캔, 병렬화 없음)의 결과. 요점은 절대값이 아니라 **cold(675.2초) vs warm(675.85초)의 델타 = 0**이라는 상대 비교. 이런 무거운 cross-session 집계는 애초에 즉석 동기 조회가 아니라 배치 사전계산이나 별도 분석 경로(S3/데이터레이크, §0)로 빼야 한다는 근거로만 쓸 것 — "우리 쿼리는 11분 걸린다"는 헤드라인으로 오버클레임 금지. - **결과 (generated column) ⬜ 미수행 결정**: ⓐ프로젝트 실수요 0(JSON 내부값으로 검색 안 함, `sync_rate`는 별도 컬럼) ⓑ합성 rig 가 단일 템플릿 복제라 **값 분포(카디널리티) 균일**(`_pose_template` 750행 `d_whole_json=1`) → 선택도 실험 불가. **세 번째 축(값 분포) 한계**로 정직 박제(아래 §5 캐비엇). ⑤ 옵티마이저도 동일 제약. - **면접**: "MVCC 덕에 적재 트랜잭션이 조회를 블로킹 안 함(RR 스냅샷, data_locks 0). 트림은 33→13 으로 −60.9%지만 z·visibility 실사용이라 키는 못 줄임, 진짜 해법은 raw 를 OLTP 에서 빼는 것. 버퍼풀은 **작업셋 vs 메모리** — 540MB 스캔이 128MB 풀에선 warm 도 매번 485MB 재읽기, 그리고 **read-ahead 때문에 표준 hit율 공식이 거짓 99% 라 `data_read` 바이트로 봐야 한다**. AWS로 진짜 2.3KB JSON·1억 행에서도 재확인 — 8.6GB 작업셋은 warm 해도 675초 그대로(데이터 읽기 12.8GB 불변), 19MB 작업셋은 warm 시 0.46초·디스크 0바이트로 완전 대조." @@ -245,6 +253,7 @@ | Ch.5 갭/넥스트키 락 | 🟡 append-only 저경합이라 일부 **합성 구간** — 명시 | | 합성데이터 **값 분포** | 🟡 rig 가 단일 템플릿 복제라 **카디널리티 균일**(`d_whole_json=1`). 행수·payload(크기)는 진짜지만 값 분포는 가짜 → 분포 의존 실험(generated column 선택도·옵티마이저 카디널리티)은 **미수행**. "구조 실험만 골라 돌렸다"가 정직 | | 버퍼풀 hit율 | 🟡 표준 공식(1−`reads`/`req`)은 **read-ahead 가 가려 거짓 99%** — 순차 스캔은 `read_ahead`+`Innodb_data_read`(바이트)로 봐야 함(§4 ④) | +| 옵티마이저 크로스오버 지점 | 🟡 15,000~18,000세션 구간은 **깔끔한 이분점이 아니라 흔들림**(16,000→ALL, 17,000→range, 18,000+→ALL) — 두 플랜 비용 추정치가 그 근방에서 거의 같아 통계(`ANALYZE TABLE` 샘플링) 오차가 선택을 좌우함. "여기서 정확히 전환된다"가 아니라 "이 근방(11~13%)에서 전환된다"가 정직한 표현(§4 ⑤). **절대 세션 수(15,000~18,000)도 이 테이블이 지금 133,334세션이라 나온 값** — 본질은 비율(~11~13%)이고, 테이블이 커지면 절대 수치도 비례해 커질 것으로 추정되나 실측·확인은 안 함 | | Ch.8 인덱스 | "추가로 빨라짐" ❌ → "**이미 최적임을 측정으로 발견**"이 정직한 헤드라인(§4.3) | | 대용량 쿼리 저하 | 세션 리포트는 안 느려짐. **cross-session 집계**에서만 성립 | | throughput | DAU 작음(§4.2) — 처리량 자랑 ❌, 데이터량·정합성 축 | diff --git a/docs/tasks/27-implementation-gaps.md b/docs/tasks/27-implementation-gaps.md new file mode 100644 index 0000000..de4ec40 --- /dev/null +++ b/docs/tasks/27-implementation-gaps.md @@ -0,0 +1,40 @@ +# 구현 갭 정리 (2026-07-20 코드 검증 기준) + +작성: 2026-07-20. 오늘 대화 중 실제 코드/문서로 재검증해서 확인한 미구현·미결정 항목 정리. `22-backend-tasks-detail.md`는 방대하지만 일부 항목이 실제 구현 상태를 못 따라가고 있어(예: 세션 피드백 조회 API는 실제로는 구현돼 있는데 그 문서엔 미구현으로 표시) 전체 신뢰는 어려움 — 이 문서는 오늘 직접 코드 확인한 것만 담음. + +--- + +## 1. DB/백엔드 핵심 — 오늘 코드로 확인한 미구현 + +- [ ] **precompute-on-write** — 리포트 worst 구간을 세션 종료 시 미리 계산해 `reports`에 저장하는 구조. 지금은 `ReportService.getSessionReport`가 조회할 때마다 `pose_data`를 즉석 재계산(`selectWorstSection`). 설계는 [`db-deep-dive.md §B-3`](../portfolio/db-deep-dive.md) ⬜. +- [ ] **TTL 자동 만료 스케줄러** — `mysql/schema.sql`에 파티션 스키마(월별 RANGE, PK `(id, created_at)`)는 PR #43로 반영됨. 근데 오래된 파티션을 주기적으로 `DROP PARTITION`하는 `@Scheduled` 잡이 코드에 없음. 설계는 [`db-deep-dive.md §D`](../portfolio/db-deep-dive.md). +- [ ] **개별 세션 삭제 기능** — 세션 1건만 지우는 API 자체가 없음(회원 탈퇴 전체 삭제 경로만 있음). [`pose-data-partition-fk-tradeoff.md §1-1`](../decisions/pose-data-partition-fk-tradeoff.md)에서 발견된 별도 feature gap. +- [ ] **report 생성 멱등성** — `reports`에 `session_id` 유니크 제약 없음, 세션 종료 재시도 시 중복 생성 가능성. [`db-deep-dive.md §C`](../portfolio/db-deep-dive.md)에 🔶 후보로 남아있음. + +## 2. 판단 완료, 착수 여부만 미결정 + +- [ ] **다운샘플링(1초 다운샘플)** — 위치(A: AI서버 / B: Spring / C: 안 함)도, 착수 여부도 미결정. 효과는 실측 완료(R≈5에서 RPS +126%, p99 −70%, 저장 5배↓). [`pose-ingest-downsampling.md §7`](../decisions/pose-ingest-downsampling.md). +- [ ] **Redis 도입** — "MVP 단계 MySQL 부족 미증명"으로 보류 확정(CLOSED). T1~T5 트리거 발생 시 재검토. [`redis-introduction.md`](../decisions/redis-introduction.md). + +## 3. 이미 스스로 인지한 약점 + +- [ ] **관측성/모니터링/N+1 점검** — `portfolio-narrative.md` §6에 🔴로 자체 표시. +- [ ] **테스트 커버리지** — 테스트 파일 5개뿐. +- [ ] **외부 통합 다양성 부족** — OAuth2·S3·결제·푸시·검색 등 0개. [`25-portfolio-strategy.md`](./25-portfolio-strategy.md). +- [ ] **AI→Spring 콜백 방향 장애 보호 없음** — 서킷브레이커/재시도가 Spring→AI 방향에만 있음, 반대 방향은 fire-and-forget(의도적 스코프 제외, `production-signal-checklist.md` §2-3-4-2). + +## 4. 남겨둔 검증 작업(코드 아님, 실험) + +- [ ] **소량 DELETE 반복 시 파편화 실험** — 미실험. 메모리: `project_pending_delete_fragmentation_experiment`. +- [ ] **풀 사이징 AWS(RDS) 재검증** — 로컬 2코어 동거 환경 종속 결론, 비용 문제로 미착수·미결정. 메모리: `project_pending_aws_pool_sizing_reverify`. + +## 5. 2학기 계획 범위(지금 범위 밖) + +- [ ] **다른 운동 종목 확장**(런지·플랭크 등) — 지금은 스쿼트만. `project_squat_first` 방침. +- [ ] **베타 테스트/실사용자 피드백** — `24-semester2-plan.md` 참고. + +--- + +## 우선순위 제안 + +인터뷰 준비 관점에서는 **1번(DB 핵심 미구현)**이 가장 중요 — 오늘 리뷰한 실험 깊이(precompute·TTL·정합성) 바로 다음 단계라 자연스럽게 이어지는 이야기가 됨. 2~3번은 "왜 아직 안 했는지"를 정직하게 설명할 수 있으면 충분. 4~5번은 지금 당장 급하지 않음. diff --git a/mysql/data.sql b/mysql/data.sql index 9110007..92ca62b 100644 --- a/mysql/data.sql +++ b/mysql/data.sql @@ -1,107 +1,29 @@ -- 인코딩 강제 (한글 깨짐 방지). 클라이언트 charset이 latin1 이어도 utf8mb4 로 협상. SET NAMES utf8mb4; --- 1. 기존 데이터 및 테이블 정리 -SET FOREIGN_KEY_CHECKS = 0; -DROP TABLE IF EXISTS body_records, reports, daily_logs, pose_data, session_feedback_logs, exercise_feedback_templates, exercise_sessions, exercise_references, exercises, refresh_token, users; -SET FOREIGN_KEY_CHECKS = 1; - --- 2. 사용자 테이블 (자바 Member 엔티티와 1:1 매칭) -CREATE TABLE users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(100) UNIQUE NOT NULL, - password VARCHAR(1000) NOT NULL, - username VARCHAR(50) UNIQUE NOT NULL, -- nullable = false 대응 - role VARCHAR(20) NOT NULL, -- Enum (USER, ADMIN) - selected_persona VARCHAR(10) NOT NULL DEFAULT 'BEGINNER', - preferred_url VARCHAR(500), -- preferredUrl -> preferred_url (언더바) - height DECIMAL(5,1), - weight DECIMAL(5,1), - workout_level VARCHAR(20), - onboarding_completed BOOLEAN NOT NULL DEFAULT FALSE, - sex VARCHAR(10), - tts_enabled BOOLEAN NOT NULL DEFAULT TRUE, - tts_speed DECIMAL(3,1) NOT NULL DEFAULT 1.0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- 3. 운동 테이블 (자바 Exercise 엔티티와 1:1 매칭) -CREATE TABLE exercises ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(100) NOT NULL, - category VARCHAR(20) NOT NULL, -- LOWER, CORE 등 - description TEXT, - preferred_url VARCHAR(500), -- Preferredurl -> preferred_url (언더바) - target_joints JSON, - sync_threshold_beginner DECIMAL(5,2) DEFAULT 60.00, - sync_threshold_advanced DECIMAL(5,2) DEFAULT 85.00, - expected_duration_minutes INT DEFAULT 15, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS exercise_sessions ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - member_id BIGINT NOT NULL, - exercise_id BIGINT NOT NULL, - reference_source VARCHAR(500), - start_time DATETIME NOT NULL, - end_time DATETIME, - total_reps INT DEFAULT 0, - avg_sync_rate DECIMAL(5,2), - max_sync_rate DECIMAL(5,2), - min_sync_rate DECIMAL(5,2), - calories_burned DECIMAL(7,2), - difficulty_level INT DEFAULT 1, - status VARCHAR(20) DEFAULT 'IN_PROGRESS', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (member_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) - ); - -CREATE TABLE reports ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 자동 증가 추가 - session_id BIGINT, - member_id BIGINT, - report_type VARCHAR(20), - summary TEXT, - improvement_tips TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES exercise_sessions(id), - FOREIGN KEY (member_id) REFERENCES users(id) -); - -CREATE TABLE IF NOT EXISTS refresh_token ( - member_id BIGINT PRIMARY KEY, - token VARCHAR(255) NOT NULL, - FOREIGN KEY (member_id) REFERENCES users(id) ON DELETE CASCADE - ); - -CREATE TABLE IF NOT EXISTS exercise_feedback_templates ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - exercise_id BIGINT NOT NULL, - feedback_type VARCHAR(30) NOT NULL, - persona VARCHAR(10) NULL, - message VARCHAR(200) NOT NULL, - priority INT NOT NULL DEFAULT 100, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, - UNIQUE KEY uk_exercise_feedback_persona (exercise_id, feedback_type, persona) -); - -CREATE TABLE IF NOT EXISTS session_feedback_logs ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - session_id BIGINT NOT NULL, - feedback_type VARCHAR(30) NOT NULL, - sync_rate_at_trigger DECIMAL(5,2), - occurred_at DATETIME NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES exercise_sessions(id) ON DELETE CASCADE, - INDEX idx_session_feedback (session_id, occurred_at), - UNIQUE KEY uk_session_event (session_id, occurred_at, feedback_type) -); - --- 3. 데이터 삽입 시작 +-- 이 파일은 순수 시드 데이터 전용 (초기 시드). 테이블 구조는 schema.sql이 전담 — +-- 여기서 CREATE TABLE을 만들지 않는다. docker-entrypoint-initdb.d는 파일을 알파벳 +-- 순으로 실행하므로 docker-compose.yml에서 01-schema.sql / 02-data.sql로 순서를 강제해 +-- schema.sql이 먼저 실행된 뒤 이 파일이 그 위에 시드를 얹는 구조. +-- (예전엔 이 파일이 자체 CREATE TABLE을 갖고 있어 schema.sql과 구조가 갈라졌었음 — +-- exercise_sessions.version 컬럼·pose_data 파티셔닝·reports의 JSON 컬럼 등이 누락된 +-- 구버전 스키마가 만들어지는 문제였음. 2026-07-22 정리.) + +-- 1. 기존 시드 데이터 정리 (테이블 구조는 그대로 두고 행만 비움) SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE body_records; +TRUNCATE TABLE reports; +TRUNCATE TABLE daily_logs; +TRUNCATE TABLE pose_data; +TRUNCATE TABLE session_feedback_logs; +TRUNCATE TABLE exercise_feedback_templates; +TRUNCATE TABLE exercise_sessions; +TRUNCATE TABLE exercise_references; +TRUNCATE TABLE exercises; +TRUNCATE TABLE refresh_token; +TRUNCATE TABLE users; + +-- 2. 데이터 삽입 시작 -- 1. 유저 데이터 (ID 1번 확실히 생성) INSERT INTO users (email, password, username, role, onboarding_completed, preferred_url) diff --git a/mysql/schema.sql b/mysql/schema.sql index e6e436c..226585e 100644 --- a/mysql/schema.sql +++ b/mysql/schema.sql @@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS refresh_token ( member_id BIGINT PRIMARY KEY, token VARCHAR(512) NOT NULL, - FOREIGN KEY (member_id) REFERENCES users(id) + FOREIGN KEY (member_id) REFERENCES users(id) ON DELETE CASCADE ); -- 3. 운동 종목 마스터 @@ -43,6 +43,8 @@ CREATE TABLE IF NOT EXISTS exercises ( target_joints JSON, sync_threshold_beginner DECIMAL(5,2) DEFAULT 60.00, sync_threshold_advanced DECIMAL(5,2) DEFAULT 85.00, + sync_threshold_diet DECIMAL(5,2) DEFAULT 70.00, + sync_threshold_rehab DECIMAL(5,2) DEFAULT 50.00, expected_duration_minutes INT DEFAULT 15, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- DEFAULT 추가 );