Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
docs/

### STS ###
.apt_generated
Expand Down Expand Up @@ -39,3 +40,6 @@ src/main/resources/*.yml
### VS Code ###
.vscode/


# 기타
CLAUDE.md
132 changes: 132 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Tech Stack

- **Java 21 / Spring Boot 4.0.1** (Gradle)
- **PostgreSQL 16 + PostGIS** (공간 쿼리), **Redis 7** (캐시)
- **Spring Security + JWT** (Stateless, `Authorization: Bearer <token>`)
- **WebSocket / STOMP** (`/ws-stomp` 엔드포인트)
- **AWS S3** (프로필·클럽·게시글 이미지)
- **Swagger UI** → `http://localhost:8080/swagger-ui/index.html`

---

## Commands

```bash
# 로컬 인프라 실행 (PostgreSQL+PostGIS, Redis, Prometheus, Grafana)
docker compose -f docker/docker-compose.local.yml up -d

# 빌드
./gradlew build

# 로컬 프로필로 실행 (application-local.properties 사용)
./gradlew bootRun --args='--spring.profiles.active=local'

# 테스트 전체 실행
./gradlew test

# 단일 테스트 클래스 실행
./gradlew test --tests "com.be.sportizebe.domain.user.service.UserServiceImplTest"
```

### 로컬 환경 변수 (application-local.properties가 읽는 값)

`application-local.properties`는 환경 변수로 주입받는 값이 있다.
IDE나 `.env` 파일에 아래를 설정해야 앱이 기동된다.

```
POSTGRES_USER=angora
POSTGRES_PASSWORD=password
AWS_ACCESS_KEY=...
AWS_SECRET_KEY=...
```

---

## Project Structure

```
src/main/java/com/be/sportizebe/
├── domain/ # 비즈니스 도메인 (아래 참고)
│ ├── auth/
│ ├── user/
│ ├── club/
│ ├── post/
│ ├── comment/
│ ├── like/
│ ├── match/
│ ├── facility/
│ ├── notification/
│ └── chat/
└── global/ # 공통 인프라
├── cache/ # Redis 캐시 설정, UserCacheService, UserAuthInfo DTO
├── config/ # CORS, S3, Redis, Swagger, Jackson 설정
├── exception/ # CustomException, GlobalExceptionHandler, BaseErrorCode
├── jwt/ # JwtProvider, JwtAuthenticationFilter
├── response/ # BaseResponse<T> (공통 응답 래퍼)
├── s3/ # S3Service (upload/delete), PathName enum
└── security/ # SecurityConfig, CustomUserDetailService
```

각 도메인은 `controller / dto / entity / repository / service / exception` 패키지로 분리된다.

---

## Key Architecture Decisions

### 인증 흐름
`JwtAuthenticationFilter` → `UserCacheService.findUserAuthInfoById()` → Redis 캐시 (TTL 5분, 미스 시 DB 조회)
`@AuthenticationPrincipal UserAuthInfo`로 컨트롤러에서 꺼낸다. `User` 엔티티가 아닌 `UserAuthInfo` (Serializable DTO)를 principal로 사용하는 이유는 JPA 연관관계로 인한 직렬화 문제를 방지하기 위함이다.

### 보안 규칙 (SecurityConfig)
- `GET /api/**` — 인증 없이 허용
- `POST /api/auth/**`, `POST /api/users/signup` — 인증 없이 허용
- `/ws-stomp/**` — 인증 없이 허용
- 그 외 — `authenticated()`

### 캐시 전략 (Redis)
| 캐시명 | 키 | TTL | 무효화 시점 |
|--------|----|-----|------------|
| `userAuthInfo` | `userId` | 5분 | 프로필/이미지 수정 |
| `postList` | property+pageable | 5분 | 게시글 생성/수정/삭제 |
| `commentList` | `postId` | 5분 | 댓글 생성/삭제 |
| `commentCount` | `postId` | 5분 | 댓글 생성/삭제 |
| `likeCount` / `likeStatus` | `targetType:targetId` | 5분 | 좋아요 토글 |
| `facilityNear` / `facilityMarkers` | 좌표+반경 | 5분 | — |

### 공간 쿼리 (Facility)
`SportsFacilityRepository`는 PostGIS Native SQL (`ST_DWithin`, `ST_Distance`) 사용.
`SportsFacility.location`은 JTS `Point` (SRID 4326). `hibernate-spatial` 의존성 필요.

### WebSocket / STOMP 채팅
- 연결: `ws://host/ws-stomp`
- 메시지 발행: `SEND /pub/chat.send | chat.join | chat.leave`
- 구독: `SUBSCRIBE /sub/chat/rooms/{roomId}`
- 실시간 알림 구독: `SUBSCRIBE /sub/notifications/{userId}`
- 채팅방 종류: `GROUP` (동호회 생성 시 자동 생성), `NOTE` (게시글 기반 1:1 쪽지)

### 파일 업로드 (S3)
`S3Service.uploadFile(PathName, MultipartFile)` → S3 버킷의 `{PathName}/{UUID}.{ext}` 경로에 저장.
`PathName` enum: `PROFILE`, `CLUB`, `POST`.

### 응답 규격
모든 REST 응답은 `BaseResponse<T>` 래퍼를 사용한다:
```json
{ "success": true, "message": "...", "data": { ... } }
```
에러는 `GlobalExceptionHandler`가 `CustomException(BaseErrorCode)` 를 잡아 동일 포맷으로 반환한다.

---

## Git Convention

커밋 접두사 (이모지 + 태그):
- `✨ Feat:` 새 기능 | `🐛 Fix:` 버그 수정 | `♻️ Refactor:` 리팩토링
- `🔧 Settings:` 설정 변경 | `📝 Docs:` 문서 | `🔥 Remove:` 파일 삭제
- `⏪️ Revert:` 롤백 | `🚀 Deploy:` 배포

브랜치: `feature/{description}` → `develop` → `main`
서브모듈 업데이트 시 커밋 메시지: `"submodule push"` / `"submodule latest"`
43 changes: 42 additions & 1 deletion docker/docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,47 @@ services:
interval: 10s
timeout: 3s
retries: 5
redis-exporter:
image: oliver006/redis_exporter:v1.62.0
container_name: sportize-redis-exporter-local
restart: unless-stopped
ports:
- "9121:9121"
environment:
REDIS_ADDR: redis://sportize-redis-local:6379
REDIS_PASSWORD: password
depends_on:
- redis
prometheus:
image: prom/prometheus:v2.52.0
container_name: sportize-prometheus-local
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus.local.yml:/etc/prometheus/prometheus.yml:ro
- sportize_prometheus_data_local:/prometheus
depends_on:
- redis-exporter
grafana:
image: grafana/grafana:10.4.2
container_name: sportize-grafana-local
restart: unless-stopped
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
TZ: Asia/Seoul
volumes:
- sportize_grafana_data_local:/var/lib/grafana
depends_on:
- prometheus


volumes:
sportize_pgdata_local:
sportize_redisdata_local:
sportize_redisdata_local:
sportize_prometheus_data_local:
sportize_grafana_data_local:
7 changes: 7 additions & 0 deletions docker/prometheus.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
global:
scrape_interval: 5s

scrape_configs:
- job_name: "redis-exporter"
static_configs:
- targets: ["redis-exporter:9121"]
4 changes: 3 additions & 1 deletion src/main/java/com/be/sportizebe/common/enums/SportType.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@

public enum SportType {
SOCCER,
BASKETBALL
BASKETBALL,
BADMINTON,

}
Comment on lines 3 to 8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find and display FacilityType enum definition
find . -name "FacilityType.java" -type f | head -5

Repository: Sportize/be

Length of output: 129


🏁 Script executed:

#!/bin/bash
# Search for SportType and FacilityType usage in the codebase
rg -n "SportType|FacilityType" --type=java -g '!**/test/**' | head -100

Repository: Sportize/be

Length of output: 5937


🏁 Script executed:

#!/bin/bash
# Look for any mapping or relationship between SportType and FacilityType
rg -n "facility.*sport|sport.*facility" --type=java -i -g '!**/test/**' -C2 | head -80

Repository: Sportize/be

Length of output: 9139


🏁 Script executed:

cat -n ./src/main/java/com/be/sportizebe/domain/facility/entity/FacilityType.java

Repository: Sportize/be

Length of output: 280


🏁 Script executed:

# Check MatchRoom entity to see how facility and sport are connected
cat -n ./src/main/java/com/be/sportizebe/domain/match/entity/MatchRoom.java | head -50

Repository: Sportize/be

Length of output: 1687


🏁 Script executed:

# Look for any mapping or validation logic between facility and sport types
rg -n "FacilityType.*SportType|SportType.*FacilityType" --type=java -g '!**/test/**'

Repository: Sportize/be

Length of output: 37


🏁 Script executed:

# Check if there's any validation logic that constrains facility type to sport type
rg -n "facilityId|sportsName" --type=java -g '!**/test/**' -C3 | grep -A5 -B5 "facility\|sport" | head -100

Repository: Sportize/be

Length of output: 10188


🏁 Script executed:

# Check MatchCreateRequest to see what parameters are passed
cat -n ./src/main/java/com/be/sportizebe/domain/match/dto/request/MatchCreateRequest.java

Repository: Sportize/be

Length of output: 812


🏁 Script executed:

# Check match service/controller to see if there's validation between facility and sport
rg -n "createMatch|MatchRoom\|create" --type=java src/main/java/com/be/sportizebe/domain/match/ -A5 | head -80

Repository: Sportize/be

Length of output: 2770


매칭 생성 시 스포츠 종목과 체육시설 타입 간 검증 로직 부재

SportType은 3가지(SOCCER, BASKETBALL, BADMINTON)만 지원하지만 FacilityType은 6가지를 지원합니다. 현재 구조에서는 매칭 생성 시 SportTypefacilityId를 독립적으로 받아 처리하며, 두 값 간의 유효성 검증이 없습니다. 따라서 사용자가 SOCCER 매칭을 TENNIS 시설에 생성하는 것을 방지하지 못합니다. MatchCreateRequest에서 선택한 스포츠 종목이 해당 시설에서 실제로 지원되는 종목인지 검증하는 로직을 추가하거나, SportType을 확장하여 두 enum을 정렬해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/be/sportizebe/domain/user/entity/SportType.java` around
lines 3 - 8, Sport and facility types are not validated together, allowing e.g.
SOCCER matches at TENNIS facilities; add a validation that ensures the chosen
SportType is supported by the FacilityType before creating a match. Implement
this by adding a support-check method (e.g., a boolean supports(SportType) on
FacilityType or a static mapping in SportType) and call it from the match
creation flow (e.g., in MatchCreateRequest validation or in the MatchService
method that handles creation) to reject/throw when the facilityId's FacilityType
does not support the requested SportType. Ensure the check uses the FacilityType
enum value resolved from facilityId and produces a clear validation error if
unsupported.

Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java
package com.be.sportizebe.domain.facility.controller;

import com.be.sportizebe.domain.facility.dto.request.FacilityCreateRequest;
import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest;
import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest;
import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityResponse;
import com.be.sportizebe.domain.facility.service.SportsFacilityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand Down Expand Up @@ -40,4 +42,16 @@ public List<FacilityMarkerResponse> markers(
) {
return sportsFacilityService.getMarkers(request);
}

@Operation(summary = "체육시설 등록", description = "체육시설을 등록합니다. (관리자/개발자용)")
@PostMapping
public FacilityResponse create(@Valid @RequestBody FacilityCreateRequest request) {
return sportsFacilityService.create(request);
}
Comment on lines +46 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

create 엔드포인트에 인증/인가 처리가 누락되었습니다.

Swagger 설명에 "(관리자/개발자용)"이라고 명시되어 있지만, 실제로 권한 검증 로직(@PreAuthorize, @Secured 등)이 없어 누구나 체육시설을 등록할 수 있습니다. 또한 POST 요청의 응답 상태 코드가 기본값 200이 아닌 201(Created)이어야 합니다.

🔒 인가 및 응답 상태 코드 개선 제안
     `@Operation`(summary = "체육시설 등록", description = "체육시설을 등록합니다. (관리자/개발자용)")
     `@PostMapping`
-    public FacilityResponse create(`@Valid` `@RequestBody` FacilityCreateRequest request) {
-        return sportsFacilityService.create(request);
+    `@ResponseStatus`(HttpStatus.CREATED)
+    public FacilityResponse create(`@Valid` `@RequestBody` FacilityCreateRequest request) {
+        return sportsFacilityService.create(request);
     }

인가 처리는 프로젝트의 보안 설정 방식에 따라 별도로 적용해 주세요.

🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java`
around lines 46 - 50, The create endpoint in SportsFacilityController (method
create handling FacilityCreateRequest and returning FacilityResponse) lacks
authorization and returns the default 200; add role-based authorization (e.g.,
annotate the method with `@PreAuthorize`("hasRole('ADMIN') or
hasRole('DEVELOPER')") or `@Secured` as appropriate per the project's security
setup) to restrict access, and change the response to return HTTP 201 Created
(either annotate the method with `@ResponseStatus`(HttpStatus.CREATED) or return a
ResponseEntity<FacilityResponse> with status CREATED) so the endpoint both
enforces permissions and returns the correct status.


@Operation(summary = "체육시설 단건 조회", description = "facilityId로 체육시설을 단건 조회합니다.")
@GetMapping("/{facilityId}")
public FacilityResponse getById(@PathVariable Long facilityId) {
return sportsFacilityService.getById(facilityId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.be.sportizebe.domain.facility.dto.request;

import com.be.sportizebe.domain.facility.entity.FacilityType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

@Schema(description = "체육시설 등록 요청")
public record FacilityCreateRequest(

@Schema(description = "체육시설 이름", example = "OO 풋살장")
@NotBlank
String facilityName,

@Schema(description = "시설 소개", example = "샤워실/주차장 있음")
String introduce,

@Schema(description = "썸네일 이미지 URL", example = "https://example.com/facility/thumbnail.jpg")
String thumbnailUrl,

@Schema(description = "시설 종목 타입", example = "SOCCER")
@NotNull
FacilityType facilityType,

@Schema(description = "위도", example = "37.563")
@DecimalMin("-90.0")
@DecimalMax("90.0")
double lat,

@Schema(description = "경도", example = "126.982")
@DecimalMin("-180.0")
@DecimalMax("180.0")
double lng
Comment on lines +27 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

lat/lng에 primitive double 사용 시 JSON에서 값이 누락되면 기본값 0.0으로 바인딩됩니다.

double 원시 타입은 JSON에서 해당 필드가 누락되어도 0.0으로 바인딩되어, 유효 좌표 범위 내이므로 validation을 통과합니다. Double 래퍼 타입 + @NotNull을 사용하면 좌표 누락을 명시적으로 거부할 수 있습니다.

🛡️ 래퍼 타입 사용 제안
         `@Schema`(description = "위도", example = "37.563")
+        `@NotNull`
         `@DecimalMin`("-90.0")
         `@DecimalMax`("90.0")
-        double lat,
+        Double lat,

         `@Schema`(description = "경도", example = "126.982")
+        `@NotNull`
         `@DecimalMin`("-180.0")
         `@DecimalMax`("180.0")
-        double lng
+        Double lng
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Schema(description = "위도", example = "37.563")
@DecimalMin("-90.0")
@DecimalMax("90.0")
double lat,
@Schema(description = "경도", example = "126.982")
@DecimalMin("-180.0")
@DecimalMax("180.0")
double lng
`@Schema`(description = "위도", example = "37.563")
`@NotNull`
`@DecimalMin`("-90.0")
`@DecimalMax`("90.0")
Double lat,
`@Schema`(description = "경도", example = "126.982")
`@NotNull`
`@DecimalMin`("-180.0")
`@DecimalMax`("180.0")
Double lng
🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/facility/dto/request/FacilityCreateRequest.java`
around lines 27 - 35, In FacilityCreateRequest change the primitive fields lat
and lng to the wrapper type Double and annotate them with `@NotNull` so missing
JSON keys won't silently bind to 0.0; update the declarations for lat and lng in
the FacilityCreateRequest class and ensure imports for
javax.validation.constraints.NotNull (or jakarta equivalent) are added and
existing `@DecimalMin/`@DecimalMax stay applied.


) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.be.sportizebe.domain.facility.dto.response;

import com.be.sportizebe.domain.facility.entity.FacilityType;
import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "체육시설 단건 조회 응답")
public record FacilityResponse(

@Schema(description = "체육시설 ID", example = "123")
Long id,

@Schema(description = "체육시설 이름", example = "OO 풋살장")
String facilityName,

@Schema(description = "시설 소개", example = "잔디 상태가 좋아요")
String introduce,

@Schema(description = "썸네일 이미지 URL", example = "https://example.com/facility/thumbnail.jpg")
String thumbnailUrl,

@Schema(description = "시설 종목 타입", example = "SOCCER")
FacilityType facilityType,

@Schema(description = "위도", example = "37.563")
double lat,

@Schema(description = "경도", example = "126.982")
double lng

) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityResponse;
import com.be.sportizebe.domain.facility.entity.SportsFacility;
import com.be.sportizebe.domain.facility.repository.projection.FacilityMarkerProjection;
import com.be.sportizebe.domain.facility.repository.projection.FacilityNearProjection;

Expand All @@ -27,4 +29,18 @@ static FacilityMarkerResponse toMarkerResponse(FacilityMarkerProjection p){
.lng(p.getLng())
.build();
}
static FacilityResponse toFacilityResponse(SportsFacility sf) {
double lat = sf.getLocation().getY();
double lng = sf.getLocation().getX();

return new FacilityResponse(
sf.getId(),
sf.getFacilityName(),
sf.getIntroduce(),
sf.getThumbnailUrl(),
sf.getFacilityType(),
lat,
lng
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import com.be.sportizebe.domain.facility.entity.SportsFacility;
import com.be.sportizebe.domain.facility.repository.projection.FacilityMarkerProjection;
import com.be.sportizebe.domain.facility.repository.projection.FacilityNearProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import java.util.List;

// Java에서 거리를 계산하는게 아니라, DB가 돌린 결과 "숫자"를 받아오는 거다.
public interface SportsFacilityRepository extends Repository<SportsFacility, Long> {
public interface SportsFacilityRepository extends JpaRepository<SportsFacility, Long> {
// 주변 가까운 체육시설 조회 쿼리
@Query(value = """
SELECT
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.be.sportizebe.domain.facility.service;

import com.be.sportizebe.domain.facility.dto.request.FacilityCreateRequest;
import com.be.sportizebe.domain.facility.dto.request.FacilityMarkerRequest;
import com.be.sportizebe.domain.facility.dto.request.FacilityNearRequest;
import com.be.sportizebe.domain.facility.dto.response.FacilityMarkerResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityNearResponse;
import com.be.sportizebe.domain.facility.dto.response.FacilityResponse;

import java.util.List;

Expand All @@ -16,4 +18,8 @@ public interface SportsFacilityService {
List<FacilityMarkerResponse> getMarkers(FacilityMarkerRequest request); // 지도 중심 좌표 기준 반경 내 체육시설 마커 목록 조회
// @Param: request 지도 중심 좌표, 반경, 종목 등의 조회 조건
// @return: 지도 마커용 체육시설 목록

FacilityResponse create(FacilityCreateRequest request); // 체육시설 생성

FacilityResponse getById(Long facilityId); // 체육시설 단 건 조회
}
Loading