From 2cc1832d9b85c5b36945f2e307f18275dce73588 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Fri, 22 May 2026 11:33:56 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20CLAUDE.md=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e69de29b..9212ffa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Communication + +- 한국어로 답변한다. +- "확인해줘", "봐줘", "뭐가 문제야" 같은 표현은 분석만 요청하는 것으로 간주하고 코드를 수정하지 않는다. +- "수정해줘", "고쳐줘", "반영해줘", "만들어줘", "추가해줘", "삭제해줘" 같은 명시적 요청이 있을 때만 파일을 편집한다. +- 수정 전에 어떤 파일을 왜 건드릴지 먼저 설명하고, 명시적 승인을 기다린다. + +## Build & Test Commands + +```bash +./gradlew build # 전체 빌드 +./gradlew test # 전체 테스트 +./gradlew test --tests # 단일 클래스 테스트 +./gradlew bootRun # 애플리케이션 실행 (local 프로파일) +``` + +로컬 인프라(PostgreSQL, Redis, MinIO)가 없으면 테스트가 실패할 수 있다. 인프라 부재로 인한 실패와 코드 문제로 인한 실패를 명확히 구분해서 보고한다. + +## Architecture Overview + +### Package Structure + +``` +com.semosan.api +├── common/ # 공통 인프라 (응답, 예외, JWT, 설정, FCM, 알림 공통) +└── domain/ # 도메인별 비즈니스 로직 + ├── auth/ # JWT 로그인/로그아웃/토큰 재발급, 회원탈퇴 + ├── oauth/ # 카카오·애플 소셜 로그인 + ├── user/ # 사용자 프로필, 온보딩, 알림 설정 + ├── mountain/ # 산 정보, 코스, 좋아요, 지도 검색 (PostGIS) + ├── hiking/ # 등산 기록 조회 + ├── tracking/ # 실시간 GPS 트래킹 (WebSocket STOMP + Redis Stream) + ├── community/ # 자유게시글·기록게시글, 댓글, 좋아요 + ├── image/ # MinIO presigned URL 발급 + ├── notification/# FCM 토큰, 알림 엔티티, 이벤트 + └── review/ # 산 리뷰 +``` + +### Layer Convention + +각 도메인은 `controller → service → repository` 계층을 따른다. +Swagger 문서는 컨트롤러와 분리된 `controller/docs/ControllerDocs` 인터페이스에 작성하고, 컨트롤러가 이 인터페이스를 구현한다. + +DTO는 다음 규칙을 따른다. +- 요청: `dto/request/` 하위 또는 `dto/` 직하위에 `*Request.java` +- 응답: `dto/response/` 하위 또는 `dto/` 직하위에 `*Response.java` +- 서비스 간 전달 커맨드: `dto/command/` 하위에 `*Command.java` + +### API Response Convention + +모든 컨트롤러는 `ApiResponse`로 응답을 반환한다. + +```java +// 데이터 없는 성공 +return ApiResponse.success(SuccessStatus.XXX); + +// 데이터 포함 성공 +return ApiResponse.success(SuccessStatus.XXX, data); + +// 오류 +throw new GeneralException(ErrorStatus.XXX); +``` + +`SuccessStatus`와 `ErrorStatus` 모두 `BaseStatus` 인터페이스를 구현하는 enum이다. +새 에러 코드는 `ErrorStatus`에, 성공 코드는 `SuccessStatus`에 추가한다. + +### Authentication + +- Stateless JWT 방식. `JwtFilter`가 `Authorization: Bearer ` 헤더를 검증한다. +- WebSocket(STOMP) 연결은 HTTP 필터를 통과(`/ws/tracking/**` permitAll)하고, `StompAuthChannelInterceptor`가 STOMP CONNECT 프레임에서 JWT를 검증한다. +- 퍼블릭 엔드포인트: 스웨거, OAuth 로그인, 토큰 재발급, `/api/auth/test/login` + +### Real-time GPS Tracking Flow + +1. 클라이언트가 WebSocket(STOMP)으로 GPS 좌표를 전송 +2. `TrackingGpsPublisher`가 Redis Stream(`tracking:gps`)에 publish +3. `TrackingStreamConsumer`가 스트림을 consume → 실시간 통계(Redis Hash) 갱신 + 메모리 버퍼 적재 +4. 버퍼가 100개 이상이거나 10초 주기 스케줄러가 동작하면 `TrackingPointFlushService`가 DB에 배치 insert +5. 세션 종료 시 `TrackingSessionTerminatedEvent`로 잔여 버퍼를 final flush + +`TrackingPointFlushService`는 self-invocation AOP 우회를 위해 별도 빈으로 분리되어 있다. + +### Database & Migrations + +- PostgreSQL + PostGIS (지리 좌표 검색에 `hibernate-spatial` + JTS 사용) +- **prod 프로파일**: Flyway 활성화, `ddl-auto: validate` +- **local 프로파일**: Flyway 비활성화, `ddl-auto: update` +- 마이그레이션 스크립트: `src/main/resources/db/migration/V{N}__description.sql` + +### Infrastructure + +| 컴포넌트 | 용도 | +|---------|------| +| PostgreSQL | 메인 DB | +| Redis | JWT refresh token 블랙리스트, GPS 실시간 통계 (Hash), GPS 이벤트 스트림 (Stream) | +| MinIO | 이미지 오브젝트 스토리지 (presigned URL 방식) | +| Firebase FCM | 푸시 알림 | + +### Environment Variables + +`application.yaml`은 환경변수만 참조한다. 로컬 개발은 `application-local.yaml`에 기본값이 하드코딩되어 있다(로컬 전용). +prod에서 필요한 주요 변수: `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `REDIS_HOST`, `REDIS_PORT`, `JWT_SECRET`, `JWT_ACCESS_TOKEN_EXPIRATION`, `JWT_REFRESH_TOKEN_EXPIRATION`, `KAKAO_CLIENT_ID`, `KAKAO_CLIENT_SECRET`, `KAKAO_REDIRECT_URI`, `KAKAO_ADMIN_KEY`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_PUBLIC_URL`, `TRACKING_STREAM_KEY`, `TRACKING_CONSUMER_GROUP`, `FIREBASE_SERVICE_ACCOUNT_PATH`, `DISCORD_ALERT_ENABLED`, `DISCORD_WEBHOOK_URL` + +## Branch & Commit Convention + +브랜치명: +``` +feat/#이슈번호-기능명 +fix/#이슈번호-버그명 +``` + +커밋 타입: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` + +## PR Rules + +- 리뷰어 2명 전원 승인 후 머지 (셀프 머지 금지) + +## Swagger + +로컬 실행 후 `http://localhost:8080/swagger-ui.html` 에서 API 문서 확인 가능. From 079353719a5cc01ce0438d3e9280f4936594a91a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:02:18 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A2=8C=ED=91=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V16__add_course_route_coordinates.sql | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/resources/db/migration/V16__add_course_route_coordinates.sql diff --git a/src/main/resources/db/migration/V16__add_course_route_coordinates.sql b/src/main/resources/db/migration/V16__add_course_route_coordinates.sql new file mode 100644 index 00000000..c39b1169 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_course_route_coordinates.sql @@ -0,0 +1,20 @@ +-- Live Activity에서 코스 기반 진행률/남은 거리 계산에 사용할 전체 코스 좌표 배열. +-- 형식: [{"latitude": 37.123456, "longitude": 127.123456}, ...] +ALTER TABLE courses + ADD COLUMN IF NOT EXISTS route_coordinates jsonb NOT NULL DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN courses.route_coordinates IS + 'Course route GPS coordinates for Live Activity. Array of latitude/longitude objects.'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'courses_route_coordinates_array_check' + ) THEN + ALTER TABLE courses + ADD CONSTRAINT courses_route_coordinates_array_check + CHECK (jsonb_typeof(route_coordinates) = 'array'); + END IF; +END $$; From 1b0978f19b45de023889d5e285e6f410ec843b0a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:12:52 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=ED=8B=B0=EB=B9=84=ED=8B=B0=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/status/SuccessStatus.java | 1 + .../api/domain/mountain/entity/Course.java | 14 +++++++ .../controller/TrackingController.java | 12 ++++++ .../docs/TrackingControllerDocs.java | 25 +++++++++++++ .../response/LiveActivityCourseResponse.java | 37 +++++++++++++++++++ .../tracking/service/TrackingService.java | 8 ++++ 6 files changed, 97 insertions(+) create mode 100644 src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 6034906c..f7c72867 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -62,6 +62,7 @@ public enum SuccessStatus implements BaseStatus { TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."), TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."), TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."), + TRACKING_LIVE_ACTIVITY_COURSE_SUCCESS(HttpStatus.OK, "TRK_200_8", "라이브 액티비티용 코스 정보 조회에 성공했습니다."), /** * Image diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java index b3af49fa..08b80884 100644 --- a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java @@ -4,6 +4,10 @@ import com.semosan.api.domain.mountain.enums.Difficulty; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; @Table(name = "courses") @Getter @@ -33,4 +37,14 @@ public class Course extends BaseEntity { @Column(name = "duration", nullable = false) private Integer duration; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "route_coordinates", nullable = false, columnDefinition = "jsonb") + private List routeCoordinates; + + public record RouteCoordinate( + Double latitude, + Double longitude + ) { + } } diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java index f372d36d..f1532ce8 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingController.java @@ -3,6 +3,7 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.tracking.controller.docs.TrackingControllerDocs; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import com.semosan.api.domain.tracking.service.TrackingService; import lombok.RequiredArgsConstructor; @@ -10,6 +11,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -38,4 +40,14 @@ public ResponseEntity> getNearbyMountain( NearbyMountainResponse response = trackingService.getNearbyMountain(userId, lat, lng); return ApiResponse.success(SuccessStatus.TRACKING_NEAREST_MOUNTAIN_SUCCESS, response); } + + @GetMapping("/live-activity/courses/{courseId}") + @Override + public ResponseEntity> getLiveActivityCourse( + @AuthenticationPrincipal Long userId, + @PathVariable Long courseId + ) { + LiveActivityCourseResponse response = trackingService.getLiveActivityCourse(userId, courseId); + return ApiResponse.success(SuccessStatus.TRACKING_LIVE_ACTIVITY_COURSE_SUCCESS, response); + } } diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java index 3b6ae663..fd3c28d2 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java @@ -1,6 +1,7 @@ package com.semosan.api.domain.tracking.controller.docs; import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -12,6 +13,7 @@ import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Tracking", description = "트래킹(등산 시작/진행) 관련 API") @@ -41,4 +43,27 @@ ResponseEntity> getNearbyMountain( @Parameter(description = "현재 위치 경도", required = true) @RequestParam @Min(-180) @Max(180) Double lng ); + + @Operation( + summary = "라이브 액티비티용 코스 정보 조회", + description = "코스 기반 트래킹의 Live Activity 초기화에 필요한 전체 코스 좌표 배열, " + + "전체 거리(m), 예상 소요 시간(분)을 반환합니다. 자유 기록에서는 호출하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "라이브 액티비티용 코스 정보 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "코스를 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> getLiveActivityCourse( + @Parameter(hidden = true) + @AuthenticationPrincipal Long userId, + @Parameter(description = "코스 ID", required = true) + @PathVariable Long courseId + ); } diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java new file mode 100644 index 00000000..277f685e --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java @@ -0,0 +1,37 @@ +package com.semosan.api.domain.tracking.dto.response; + +import com.semosan.api.domain.mountain.entity.Course; + +import java.util.List; + +public record LiveActivityCourseResponse( + Long courseId, + List coordinates, + Double totalDistance, + Integer estimatedTime +) { + + public static LiveActivityCourseResponse from(Course course) { + List routeCoordinates = course.getRouteCoordinates(); + return new LiveActivityCourseResponse( + course.getId(), + routeCoordinates == null + ? List.of() + : routeCoordinates.stream().map(CoordinateInfo::from).toList(), + course.getDistance(), + course.getDuration() + ); + } + + public record CoordinateInfo( + Double latitude, + Double longitude + ) { + public static CoordinateInfo from(Course.RouteCoordinate coordinate) { + return new CoordinateInfo( + coordinate.latitude(), + coordinate.longitude() + ); + } + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java index 67096b5a..bb26b920 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java @@ -5,6 +5,7 @@ import com.semosan.api.domain.mountain.entity.Mountain; import com.semosan.api.domain.mountain.repository.CourseRepository; import com.semosan.api.domain.mountain.repository.MountainRepository; +import com.semosan.api.domain.tracking.dto.response.LiveActivityCourseResponse; import com.semosan.api.domain.tracking.dto.response.NearbyMountainResponse; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; @@ -34,4 +35,11 @@ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double courseRepository.findByMountainId(mountain.getId()) ); } + + public LiveActivityCourseResponse getLiveActivityCourse(Long userId, Long courseId) { + userReader.findActiveUserById(userId); + return courseRepository.findById(courseId) + .map(LiveActivityCourseResponse::from) + .orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND)); + } } From ef553ccffd496d4e5ea3f01d85301f76687cc2f0 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:26:28 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=99=9C=EC=84=B1=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=98=ED=82=B9=20=EC=84=B8=EC=85=98=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V17__add_tracking_session_active_unique_index.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql diff --git a/src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql b/src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql new file mode 100644 index 00000000..f52068bc --- /dev/null +++ b/src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql @@ -0,0 +1,5 @@ +-- 유저당 활성 트래킹 세션(IN_PROGRESS/PAUSED)은 1개만 허용한다. +-- 애플리케이션의 exists -> save 검증만으로는 동시 생성 요청을 막을 수 없어서 DB 제약으로 보강한다. +CREATE UNIQUE INDEX IF NOT EXISTS uq_tracking_sessions_user_active + ON tracking_sessions (user_id) + WHERE status IN ('IN_PROGRESS', 'PAUSED'); From 7757031f4997ee49c2e9f045fd2023055d63fdbb Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:27:12 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A4=91=EB=B3=B5=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tracking/service/TrackingSessionService.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index bdfdc2f7..26c06b94 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,7 +54,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest Course course = resolveCourse(request, mountain); TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); - TrackingSession saved = trackingSessionRepository.save(session); + TrackingSession saved = saveSession(session); return TrackingSessionResponse.from(saved); } @@ -147,4 +148,12 @@ private Course resolveCourse(CreateTrackingSessionRequest request, Mountain moun } return course; } + + private TrackingSession saveSession(TrackingSession session) { + try { + return trackingSessionRepository.save(session); + } catch (DataIntegrityViolationException e) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); + } + } } From deb248e22baccdda79887a2f0b5f7e378822c039 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:27:36 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=98=ED=82=B9?= =?UTF-8?q?=20=ED=86=B5=EA=B3=84=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tracking/service/TrackingSessionStatsService.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java index 75a33d81..81729cf0 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java @@ -101,12 +101,8 @@ public static String statsKey(Long sessionId) { * 점이 한 번도 들어오지 않았으면 모든 필드 0/null. */ public Stats getStats(Long sessionId) { - Map entries = redisTemplate.opsForHash().entries(statsKey(sessionId)) - .entrySet().stream() - .collect(java.util.stream.Collectors.toMap( - e -> String.valueOf(e.getKey()), - e -> String.valueOf(e.getValue()) - )); + HashOperations hash = redisTemplate.opsForHash(); + Map entries = hash.entries(statsKey(sessionId)); return new Stats( parseDouble(entries.get(F_DISTANCE_TOTAL)), parseDouble(entries.get(F_ASCENT_TOTAL)), From dae0ea2420b06493686587333ddeb57a743516aa Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:28:03 +0900 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=9D=98=EB=8F=84=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semosan/api/domain/tracking/service/TrackingService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java index bb26b920..56aa7e6d 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingService.java @@ -27,6 +27,7 @@ public class TrackingService { * 산 데이터가 비어있거나 location 이 null 인 산만 존재할 경우 MOUNTAIN_NOT_FOUND 로 응답. */ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double lng) { + // JWT 인증 이후에도 탈퇴/비활성 유저의 트래킹 진입을 막기 위한 도메인 검증. userReader.findActiveUserById(userId); Mountain mountain = mountainRepository.findNearestByLatLng(lat, lng) .orElseThrow(() -> new GeneralException(ErrorStatus.MOUNTAIN_NOT_FOUND)); @@ -37,6 +38,7 @@ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double } public LiveActivityCourseResponse getLiveActivityCourse(Long userId, Long courseId) { + // JWT 인증 이후에도 탈퇴/비활성 유저의 Live Activity 코스 조회를 막기 위한 도메인 검증. userReader.findActiveUserById(userId); return courseRepository.findById(courseId) .map(LiveActivityCourseResponse::from) From ff60e1f5f48094b2f06798f6cb18248d5e3c869a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:28:26 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20=ED=8A=B8=EB=9E=98=ED=82=B9?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=9D=91=EB=8B=B5=20null=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EB=B2=94=EC=9C=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/tracking/dto/response/TrackingSessionResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java index 5ec5d352..ced7b895 100644 --- a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingSessionResponse.java @@ -6,7 +6,6 @@ import java.time.LocalDateTime; -@JsonInclude(JsonInclude.Include.NON_NULL) public record TrackingSessionResponse( Long sessionId, Long userId, @@ -21,6 +20,7 @@ public record TrackingSessionResponse( LocalDateTime pausedAt, Integer pausedSecondsTotal, /** complete 시에만 채워짐 — 변환된 HikingRecord 의 ID. 다른 경로에선 null 직렬화에서 제외. */ + @JsonInclude(JsonInclude.Include.NON_NULL) Long hikingRecordId ) { From 094c4dcfa85631e284e251564f5fa49b0877b2fa Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:37:23 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=20=EC=9D=91=EB=8B=B5=EC=9D=84=20polyline=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/mountain/entity/Course.java | 16 +++---------- .../response/LiveActivityCourseResponse.java | 23 +++++++++++++------ .../V16__add_course_route_coordinates.sql | 20 ---------------- ..._tracking_session_active_unique_index.sql} | 0 4 files changed, 19 insertions(+), 40 deletions(-) delete mode 100644 src/main/resources/db/migration/V16__add_course_route_coordinates.sql rename src/main/resources/db/migration/{V17__add_tracking_session_active_unique_index.sql => V16__add_tracking_session_active_unique_index.sql} (100%) diff --git a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java index 08b80884..0a8983a2 100644 --- a/src/main/java/com/semosan/api/domain/mountain/entity/Course.java +++ b/src/main/java/com/semosan/api/domain/mountain/entity/Course.java @@ -4,10 +4,7 @@ import com.semosan.api.domain.mountain.enums.Difficulty; import jakarta.persistence.*; import lombok.*; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.util.List; +import org.locationtech.jts.geom.LineString; @Table(name = "courses") @Getter @@ -38,13 +35,6 @@ public class Course extends BaseEntity { @Column(name = "duration", nullable = false) private Integer duration; - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "route_coordinates", nullable = false, columnDefinition = "jsonb") - private List routeCoordinates; - - public record RouteCoordinate( - Double latitude, - Double longitude - ) { - } + @Column(name = "polyline", columnDefinition = "geography(LineString, 4326)") + private LineString polyline; } diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java index 277f685e..592fa41c 100644 --- a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java @@ -1,7 +1,10 @@ package com.semosan.api.domain.tracking.dto.response; import com.semosan.api.domain.mountain.entity.Course; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.LineString; +import java.util.Arrays; import java.util.List; public record LiveActivityCourseResponse( @@ -12,25 +15,31 @@ public record LiveActivityCourseResponse( ) { public static LiveActivityCourseResponse from(Course course) { - List routeCoordinates = course.getRouteCoordinates(); return new LiveActivityCourseResponse( course.getId(), - routeCoordinates == null - ? List.of() - : routeCoordinates.stream().map(CoordinateInfo::from).toList(), + toCoordinates(course.getPolyline()), course.getDistance(), course.getDuration() ); } + private static List toCoordinates(LineString polyline) { + if (polyline == null) { + return List.of(); + } + return Arrays.stream(polyline.getCoordinates()) + .map(CoordinateInfo::from) + .toList(); + } + public record CoordinateInfo( Double latitude, Double longitude ) { - public static CoordinateInfo from(Course.RouteCoordinate coordinate) { + public static CoordinateInfo from(Coordinate coordinate) { return new CoordinateInfo( - coordinate.latitude(), - coordinate.longitude() + coordinate.y, + coordinate.x ); } } diff --git a/src/main/resources/db/migration/V16__add_course_route_coordinates.sql b/src/main/resources/db/migration/V16__add_course_route_coordinates.sql deleted file mode 100644 index c39b1169..00000000 --- a/src/main/resources/db/migration/V16__add_course_route_coordinates.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Live Activity에서 코스 기반 진행률/남은 거리 계산에 사용할 전체 코스 좌표 배열. --- 형식: [{"latitude": 37.123456, "longitude": 127.123456}, ...] -ALTER TABLE courses - ADD COLUMN IF NOT EXISTS route_coordinates jsonb NOT NULL DEFAULT '[]'::jsonb; - -COMMENT ON COLUMN courses.route_coordinates IS - 'Course route GPS coordinates for Live Activity. Array of latitude/longitude objects.'; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'courses_route_coordinates_array_check' - ) THEN - ALTER TABLE courses - ADD CONSTRAINT courses_route_coordinates_array_check - CHECK (jsonb_typeof(route_coordinates) = 'array'); - END IF; -END $$; diff --git a/src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql b/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql similarity index 100% rename from src/main/resources/db/migration/V17__add_tracking_session_active_unique_index.sql rename to src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql From 97b47428c786fbc35a0cb62636509b68dca48a47 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 17:47:22 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=EC=BD=94=EC=8A=A4=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A2=8C=ED=91=9C=20=EB=88=84=EB=9D=BD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/semosan/api/common/status/ErrorStatus.java | 1 + .../tracking/dto/response/LiveActivityCourseResponse.java | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index d4e6bbc0..738e2c82 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -114,6 +114,7 @@ public enum ErrorStatus implements BaseStatus { TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."), TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."), TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."), + TRACKING_COURSE_POLYLINE_REQUIRED(HttpStatus.UNPROCESSABLE_ENTITY, "TRK_422_1", "코스 경로 좌표가 등록되지 않았습니다."), COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java index 592fa41c..7eecf21f 100644 --- a/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/LiveActivityCourseResponse.java @@ -1,5 +1,7 @@ package com.semosan.api.domain.tracking.dto.response; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.mountain.entity.Course; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.LineString; @@ -24,8 +26,8 @@ public static LiveActivityCourseResponse from(Course course) { } private static List toCoordinates(LineString polyline) { - if (polyline == null) { - return List.of(); + if (polyline == null || polyline.isEmpty()) { + throw new GeneralException(ErrorStatus.TRACKING_COURSE_POLYLINE_REQUIRED); } return Arrays.stream(polyline.getCoordinates()) .map(CoordinateInfo::from) From a096dcae21efcf3d4d9bdf132bccd4e3b5eb20fc Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 24 May 2026 18:07:31 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20PR=20review=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 ++-- .../docs/TrackingControllerDocs.java | 5 ++++ .../service/TrackingSessionService.java | 24 ++++++++++++++++++- ...d_tracking_session_active_unique_index.sql | 19 +++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9212ffa4..34efe3ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Package Structure -``` +```text com.semosan.api ├── common/ # 공통 인프라 (응답, 예외, JWT, 설정, FCM, 알림 공통) └── domain/ # 도메인별 비즈니스 로직 @@ -108,7 +108,7 @@ prod에서 필요한 주요 변수: `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, `RED ## Branch & Commit Convention 브랜치명: -``` +```text feat/#이슈번호-기능명 fix/#이슈번호-버그명 ``` diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java index fd3c28d2..732d473a 100644 --- a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingControllerDocs.java @@ -58,6 +58,11 @@ ResponseEntity> getNearbyMountain( responseCode = "404", description = "코스를 찾을 수 없음", content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "422", + description = "코스 경로 좌표가 등록되지 않음", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) ResponseEntity> getLiveActivityCourse( diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index 26c06b94..3ce144dd 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -20,6 +20,7 @@ import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -33,6 +34,8 @@ @Transactional(readOnly = true) public class TrackingSessionService { + private static final String ACTIVE_SESSION_UNIQUE_INDEX = "uq_tracking_sessions_user_active"; + private final TrackingSessionRepository trackingSessionRepository; private final MountainRepository mountainRepository; private final CourseRepository courseRepository; @@ -153,7 +156,26 @@ private TrackingSession saveSession(TrackingSession session) { try { return trackingSessionRepository.save(session); } catch (DataIntegrityViolationException e) { - throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); + if (isActiveSessionUniqueViolation(e)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); + } + throw e; + } + } + + private boolean isActiveSessionUniqueViolation(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && ACTIVE_SESSION_UNIQUE_INDEX.equals(constraintViolation.getConstraintName())) { + return true; + } + String message = current.getMessage(); + if (message != null && message.contains(ACTIVE_SESSION_UNIQUE_INDEX)) { + return true; + } + current = current.getCause(); } + return false; } } diff --git a/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql b/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql index f52068bc..8cbe4ecf 100644 --- a/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql +++ b/src/main/resources/db/migration/V16__add_tracking_session_active_unique_index.sql @@ -1,5 +1,24 @@ -- 유저당 활성 트래킹 세션(IN_PROGRESS/PAUSED)은 1개만 허용한다. -- 애플리케이션의 exists -> save 검증만으로는 동시 생성 요청을 막을 수 없어서 DB 제약으로 보강한다. +-- 기존 중복 데이터가 있으면 사용자별 최신 활성 세션 1건만 유지하고 나머지는 ABANDONED 처리한다. +WITH ranked_active_sessions AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY started_at DESC, id DESC + ) AS row_number + FROM tracking_sessions + WHERE status IN ('IN_PROGRESS', 'PAUSED') +) +UPDATE tracking_sessions ts +SET status = 'ABANDONED', + ended_at = COALESCE(ts.ended_at, now()), + updated_at = now() +FROM ranked_active_sessions ras +WHERE ts.id = ras.id + AND ras.row_number > 1; + CREATE UNIQUE INDEX IF NOT EXISTS uq_tracking_sessions_user_active ON tracking_sessions (user_id) WHERE status IN ('IN_PROGRESS', 'PAUSED');