Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <fully.qualified.TestClass> # 단일 클래스 테스트
./gradlew bootRun # 애플리케이션 실행 (local 프로파일)
```

로컬 인프라(PostgreSQL, Redis, MinIO)가 없으면 테스트가 실패할 수 있다. 인프라 부재로 인한 실패와 코드 문제로 인한 실패를 명확히 구분해서 보고한다.

## Architecture Overview

### Package Structure

```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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/<Name>ControllerDocs` 인터페이스에 작성하고, 컨트롤러가 이 인터페이스를 구현한다.

DTO는 다음 규칙을 따른다.
- 요청: `dto/request/` 하위 또는 `dto/` 직하위에 `*Request.java`
- 응답: `dto/response/` 하위 또는 `dto/` 직하위에 `*Response.java`
- 서비스 간 전달 커맨드: `dto/command/` 하위에 `*Command.java`

### API Response Convention

모든 컨트롤러는 `ApiResponse<T>`로 응답을 반환한다.

```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 <token>` 헤더를 검증한다.
- 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 문서 확인 가능.
Original file line number Diff line number Diff line change
Expand Up @@ -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", "코스를 찾을 수 없습니다."),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.semosan.api.domain.mountain.enums.Difficulty;
import jakarta.persistence.*;
import lombok.*;
import org.locationtech.jts.geom.LineString;

@Table(name = "courses")
@Getter
Expand Down Expand Up @@ -33,4 +34,7 @@ public class Course extends BaseEntity {

@Column(name = "duration", nullable = false)
private Integer duration;

@Column(name = "polyline", columnDefinition = "geography(LineString, 4326)")
private LineString polyline;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
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;
import org.springframework.http.ResponseEntity;
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;
Expand Down Expand Up @@ -38,4 +40,14 @@ public ResponseEntity<ApiResponse<NearbyMountainResponse>> 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<ApiResponse<LiveActivityCourseResponse>> getLiveActivityCourse(
@AuthenticationPrincipal Long userId,
@PathVariable Long courseId
) {
LiveActivityCourseResponse response = trackingService.getLiveActivityCourse(userId, courseId);
return ApiResponse.success(SuccessStatus.TRACKING_LIVE_ACTIVITY_COURSE_SUCCESS, response);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -41,4 +43,27 @@ ResponseEntity<ApiResponse<NearbyMountainResponse>> 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<ApiResponse<LiveActivityCourseResponse>> getLiveActivityCourse(
@Parameter(hidden = true)
@AuthenticationPrincipal Long userId,
@Parameter(description = "코스 ID", required = true)
@PathVariable Long courseId
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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;

import java.util.Arrays;
import java.util.List;

public record LiveActivityCourseResponse(
Long courseId,
List<CoordinateInfo> coordinates,
Double totalDistance,
Integer estimatedTime
) {

public static LiveActivityCourseResponse from(Course course) {
return new LiveActivityCourseResponse(
course.getId(),
toCoordinates(course.getPolyline()),
course.getDistance(),
course.getDuration()
);
}

private static List<CoordinateInfo> toCoordinates(LineString polyline) {
if (polyline == null || polyline.isEmpty()) {
throw new GeneralException(ErrorStatus.TRACKING_COURSE_POLYLINE_REQUIRED);
}
return Arrays.stream(polyline.getCoordinates())
.map(CoordinateInfo::from)
.toList();
}

public record CoordinateInfo(
Double latitude,
Double longitude
) {
public static CoordinateInfo from(Coordinate coordinate) {
return new CoordinateInfo(
coordinate.y,
coordinate.x
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import java.time.LocalDateTime;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record TrackingSessionResponse(
Long sessionId,
Long userId,
Expand All @@ -21,6 +20,7 @@ public record TrackingSessionResponse(
LocalDateTime pausedAt,
Integer pausedSecondsTotal,
/** complete 시에만 채워짐 — 변환된 HikingRecord 의 ID. 다른 경로에선 null 직렬화에서 제외. */
@JsonInclude(JsonInclude.Include.NON_NULL)
Long hikingRecordId
) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,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));
Expand All @@ -34,4 +36,12 @@ public NearbyMountainResponse getNearbyMountain(Long userId, Double lat, Double
courseRepository.findByMountainId(mountain.getId())
);
}

public LiveActivityCourseResponse getLiveActivityCourse(Long userId, Long courseId) {
// JWT 인증 이후에도 탈퇴/비활성 유저의 Live Activity 코스 조회를 막기 위한 도메인 검증.
userReader.findActiveUserById(userId);
return courseRepository.findById(courseId)
.map(LiveActivityCourseResponse::from)
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,8 @@ public static String statsKey(Long sessionId) {
* 점이 한 번도 들어오지 않았으면 모든 필드 0/null.
*/
public Stats getStats(Long sessionId) {
Map<String, String> 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<String, String, String> hash = redisTemplate.opsForHash();
Map<String, String> entries = hash.entries(statsKey(sessionId));
return new Stats(
parseDouble(entries.get(F_DISTANCE_TOTAL)),
parseDouble(entries.get(F_ASCENT_TOTAL)),
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.