Skip to content

Commit beadb16

Browse files
안훈기안훈기
authored andcommitted
✨Feat: 내 주변 매칭 목록 조회 API 추가
1 parent 95f838f commit beadb16

File tree

10 files changed

+279
-1
lines changed

10 files changed

+279
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build/
44
!gradle/wrapper/gradle-wrapper.jar
55
!**/src/main/**/build/
66
!**/src/test/**/build/
7+
docs/
78

89
### STS ###
910
.apt_generated

CLAUDE.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Tech Stack
6+
7+
- **Java 21 / Spring Boot 4.0.1** (Gradle)
8+
- **PostgreSQL 16 + PostGIS** (공간 쿼리), **Redis 7** (캐시)
9+
- **Spring Security + JWT** (Stateless, `Authorization: Bearer <token>`)
10+
- **WebSocket / STOMP** (`/ws-stomp` 엔드포인트)
11+
- **AWS S3** (프로필·클럽·게시글 이미지)
12+
- **Swagger UI**`http://localhost:8080/swagger-ui/index.html`
13+
14+
---
15+
16+
## Commands
17+
18+
```bash
19+
# 로컬 인프라 실행 (PostgreSQL+PostGIS, Redis, Prometheus, Grafana)
20+
docker compose -f docker/docker-compose.local.yml up -d
21+
22+
# 빌드
23+
./gradlew build
24+
25+
# 로컬 프로필로 실행 (application-local.properties 사용)
26+
./gradlew bootRun --args='--spring.profiles.active=local'
27+
28+
# 테스트 전체 실행
29+
./gradlew test
30+
31+
# 단일 테스트 클래스 실행
32+
./gradlew test --tests "com.be.sportizebe.domain.user.service.UserServiceImplTest"
33+
```
34+
35+
### 로컬 환경 변수 (application-local.properties가 읽는 값)
36+
37+
`application-local.properties`는 환경 변수로 주입받는 값이 있다.
38+
IDE나 `.env` 파일에 아래를 설정해야 앱이 기동된다.
39+
40+
```
41+
POSTGRES_USER=angora
42+
POSTGRES_PASSWORD=password
43+
AWS_ACCESS_KEY=...
44+
AWS_SECRET_KEY=...
45+
```
46+
47+
---
48+
49+
## Project Structure
50+
51+
```
52+
src/main/java/com/be/sportizebe/
53+
├── domain/ # 비즈니스 도메인 (아래 참고)
54+
│ ├── auth/
55+
│ ├── user/
56+
│ ├── club/
57+
│ ├── post/
58+
│ ├── comment/
59+
│ ├── like/
60+
│ ├── match/
61+
│ ├── facility/
62+
│ ├── notification/
63+
│ └── chat/
64+
└── global/ # 공통 인프라
65+
├── cache/ # Redis 캐시 설정, UserCacheService, UserAuthInfo DTO
66+
├── config/ # CORS, S3, Redis, Swagger, Jackson 설정
67+
├── exception/ # CustomException, GlobalExceptionHandler, BaseErrorCode
68+
├── jwt/ # JwtProvider, JwtAuthenticationFilter
69+
├── response/ # BaseResponse<T> (공통 응답 래퍼)
70+
├── s3/ # S3Service (upload/delete), PathName enum
71+
└── security/ # SecurityConfig, CustomUserDetailService
72+
```
73+
74+
각 도메인은 `controller / dto / entity / repository / service / exception` 패키지로 분리된다.
75+
76+
---
77+
78+
## Key Architecture Decisions
79+
80+
### 인증 흐름
81+
`JwtAuthenticationFilter``UserCacheService.findUserAuthInfoById()` → Redis 캐시 (TTL 5분, 미스 시 DB 조회)
82+
`@AuthenticationPrincipal UserAuthInfo`로 컨트롤러에서 꺼낸다. `User` 엔티티가 아닌 `UserAuthInfo` (Serializable DTO)를 principal로 사용하는 이유는 JPA 연관관계로 인한 직렬화 문제를 방지하기 위함이다.
83+
84+
### 보안 규칙 (SecurityConfig)
85+
- `GET /api/**` — 인증 없이 허용
86+
- `POST /api/auth/**`, `POST /api/users/signup` — 인증 없이 허용
87+
- `/ws-stomp/**` — 인증 없이 허용
88+
- 그 외 — `authenticated()`
89+
90+
### 캐시 전략 (Redis)
91+
| 캐시명 || TTL | 무효화 시점 |
92+
|--------|----|-----|------------|
93+
| `userAuthInfo` | `userId` | 5분 | 프로필/이미지 수정 |
94+
| `postList` | property+pageable | 5분 | 게시글 생성/수정/삭제 |
95+
| `commentList` | `postId` | 5분 | 댓글 생성/삭제 |
96+
| `commentCount` | `postId` | 5분 | 댓글 생성/삭제 |
97+
| `likeCount` / `likeStatus` | `targetType:targetId` | 5분 | 좋아요 토글 |
98+
| `facilityNear` / `facilityMarkers` | 좌표+반경 | 5분 ||
99+
100+
### 공간 쿼리 (Facility)
101+
`SportsFacilityRepository`는 PostGIS Native SQL (`ST_DWithin`, `ST_Distance`) 사용.
102+
`SportsFacility.location`은 JTS `Point` (SRID 4326). `hibernate-spatial` 의존성 필요.
103+
104+
### WebSocket / STOMP 채팅
105+
- 연결: `ws://host/ws-stomp`
106+
- 메시지 발행: `SEND /pub/chat.send | chat.join | chat.leave`
107+
- 구독: `SUBSCRIBE /sub/chat/rooms/{roomId}`
108+
- 실시간 알림 구독: `SUBSCRIBE /sub/notifications/{userId}`
109+
- 채팅방 종류: `GROUP` (동호회 생성 시 자동 생성), `NOTE` (게시글 기반 1:1 쪽지)
110+
111+
### 파일 업로드 (S3)
112+
`S3Service.uploadFile(PathName, MultipartFile)` → S3 버킷의 `{PathName}/{UUID}.{ext}` 경로에 저장.
113+
`PathName` enum: `PROFILE`, `CLUB`, `POST`.
114+
115+
### 응답 규격
116+
모든 REST 응답은 `BaseResponse<T>` 래퍼를 사용한다:
117+
```json
118+
{ "success": true, "message": "...", "data": { ... } }
119+
```
120+
에러는 `GlobalExceptionHandler``CustomException(BaseErrorCode)` 를 잡아 동일 포맷으로 반환한다.
121+
122+
---
123+
124+
## Git Convention
125+
126+
커밋 접두사 (이모지 + 태그):
127+
- `✨ Feat:` 새 기능 | `🐛 Fix:` 버그 수정 | `♻️ Refactor:` 리팩토링
128+
- `🔧 Settings:` 설정 변경 | `📝 Docs:` 문서 | `🔥 Remove:` 파일 삭제
129+
- `⏪️ Revert:` 롤백 | `🚀 Deploy:` 배포
130+
131+
브랜치: `feature/{description}``develop``main`
132+
서브모듈 업데이트 시 커밋 메시지: `"submodule push"` / `"submodule latest"`

src/main/java/com/be/sportizebe/domain/match/controller/MatchController.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.be.sportizebe.domain.match.controller;
22

33
import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest;
4+
import com.be.sportizebe.domain.match.dto.request.MatchNearRequest;
45
import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse;
6+
import com.be.sportizebe.domain.match.dto.response.MatchNearResponse;
57
import com.be.sportizebe.domain.match.dto.response.MatchResponse;
68
import com.be.sportizebe.domain.match.service.MatchService;
79
import com.be.sportizebe.global.cache.dto.UserAuthInfo;
@@ -13,16 +15,20 @@
1315
import jakarta.validation.Valid;
1416
import lombok.RequiredArgsConstructor;
1517

18+
import org.springdoc.core.annotations.ParameterObject;
1619
import org.springframework.http.HttpStatus;
1720
import org.springframework.http.ResponseEntity;
1821
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1922
import org.springframework.web.bind.annotation.GetMapping;
23+
import org.springframework.web.bind.annotation.ModelAttribute;
2024
import org.springframework.web.bind.annotation.PathVariable;
2125
import org.springframework.web.bind.annotation.PostMapping;
2226
import org.springframework.web.bind.annotation.RequestBody;
2327
import org.springframework.web.bind.annotation.RequestMapping;
2428
import org.springframework.web.bind.annotation.RestController;
2529

30+
import java.util.List;
31+
2632
@RestController
2733
@RequiredArgsConstructor
2834
@RequestMapping("/api/matches")
@@ -61,4 +67,13 @@ public ResponseEntity<BaseResponse<MatchDetailResponse>> getMatchDetail(
6167
matchService.getMatchDetail(matchId, userAuthInfo.getId());
6268
return ResponseEntity.ok(BaseResponse.success("매칭 상세 조회 성공", response));
6369
}
70+
71+
@Operation(summary = "내 주변 매칭 목록 조회")
72+
@GetMapping("/near")
73+
public ResponseEntity<BaseResponse<List<MatchNearResponse>>> getNearMatches(
74+
@ParameterObject @Valid @ModelAttribute MatchNearRequest request
75+
) {
76+
List<MatchNearResponse> response = matchService.getNearMatches(request);
77+
return ResponseEntity.ok(BaseResponse.success("주변 매칭 목록 조회 성공", response));
78+
}
6479
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.be.sportizebe.domain.match.dto.request;
2+
3+
import com.be.sportizebe.domain.user.entity.SportType;
4+
import jakarta.validation.constraints.*;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class MatchNearRequest {
11+
12+
@NotNull(message = "위도(lat)는 필수입니다")
13+
@DecimalMin(value = "-90.0", message = "위도는 -90.0 이상이어야 합니다")
14+
@DecimalMax(value = "90.0", message = "위도는 90.0 이하여야 합니다")
15+
private Double lat;
16+
17+
@NotNull(message = "경도(lng)는 필수입니다")
18+
@DecimalMin(value = "-180.0", message = "경도는 -180.0 이상이어야 합니다")
19+
@DecimalMax(value = "180.0", message = "경도는 180.0 이하여야 합니다")
20+
private Double lng;
21+
22+
@Min(value = 100, message = "반경은 최소 100m 이상이어야 합니다")
23+
@Max(value = 10000, message = "반경은 최대 10km까지 가능합니다")
24+
private Integer radiusM = 1000;
25+
26+
private SportType sportsName;
27+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.be.sportizebe.domain.match.dto.response;
2+
3+
import com.be.sportizebe.domain.match.entity.MatchStatus;
4+
import com.be.sportizebe.domain.match.repository.projection.MatchNearProjection;
5+
import com.be.sportizebe.domain.user.entity.SportType;
6+
7+
public record MatchNearResponse(
8+
Long matchId,
9+
SportType sportsName,
10+
Long facilityId,
11+
String facilityName,
12+
int curMembers,
13+
int maxMembers,
14+
MatchStatus status,
15+
int distanceM
16+
) {
17+
public static MatchNearResponse from(MatchNearProjection p) {
18+
return new MatchNearResponse(
19+
p.getMatchId(),
20+
SportType.valueOf(p.getSportsName()),
21+
p.getFacilityId(),
22+
p.getFacilityName(),
23+
p.getCurMembers(),
24+
p.getMaxMembers(),
25+
MatchStatus.valueOf(p.getStatus()),
26+
(int) Math.round(p.getDistanceM())
27+
);
28+
}
29+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
package com.be.sportizebe.domain.match.repository;
2+
23
import com.be.sportizebe.domain.match.entity.MatchRoom;
4+
import com.be.sportizebe.domain.match.repository.projection.MatchNearProjection;
35
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
8+
9+
import java.util.List;
410

511
public interface MatchRoomRepository extends JpaRepository<MatchRoom, Long> {
12+
13+
@Query(value = """
14+
SELECT
15+
mr.id AS matchId,
16+
mr.sports_name AS sportsName,
17+
mr.facility_id AS facilityId,
18+
sf.facility_name AS facilityName,
19+
mr.cur_members AS curMembers,
20+
mr.max_members AS maxMembers,
21+
mr.status AS status,
22+
ST_Distance(
23+
sf.location,
24+
ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography
25+
) AS distanceM
26+
FROM match_rooms mr
27+
JOIN sports_facilities sf ON mr.facility_id = sf.id
28+
WHERE ST_DWithin(
29+
sf.location,
30+
ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography,
31+
:radiusM
32+
)
33+
AND mr.status = 'OPEN'
34+
AND (:sportsName IS NULL OR mr.sports_name = :sportsName)
35+
ORDER BY distanceM
36+
""", nativeQuery = true)
37+
List<MatchNearProjection> findNear(
38+
@Param("lat") double lat,
39+
@Param("lng") double lng,
40+
@Param("radiusM") int radiusM,
41+
@Param("sportsName") String sportsName
42+
);
643
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.be.sportizebe.domain.match.repository.projection;
2+
3+
public interface MatchNearProjection {
4+
Long getMatchId();
5+
String getSportsName();
6+
Long getFacilityId();
7+
String getFacilityName();
8+
Integer getCurMembers();
9+
Integer getMaxMembers();
10+
String getStatus();
11+
Double getDistanceM();
12+
}

src/main/java/com/be/sportizebe/domain/match/service/MatchService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package com.be.sportizebe.domain.match.service;
22

33
import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest;
4+
import com.be.sportizebe.domain.match.dto.request.MatchNearRequest;
45
import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse;
6+
import com.be.sportizebe.domain.match.dto.response.MatchNearResponse;
57
import com.be.sportizebe.domain.match.dto.response.MatchResponse;
68
import com.be.sportizebe.domain.user.entity.User;
79

10+
import java.util.List;
11+
812
public interface MatchService {
913

1014
MatchResponse createMatch(Long userId, MatchCreateRequest request); // 매칭 생성
@@ -13,4 +17,6 @@ public interface MatchService {
1317

1418
MatchDetailResponse getMatchDetail(Long matchId, Long userId); // 매칭방 상세 조회(방 정보 + 유저 기준 정보)
1519

20+
List<MatchNearResponse> getNearMatches(MatchNearRequest request); // 내 주변 매칭 목록 조회
21+
1622
}

src/main/java/com/be/sportizebe/domain/match/service/MatchServiceImpl.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.be.sportizebe.domain.match.service;
22

3+
import com.be.sportizebe.domain.match.dto.request.MatchNearRequest;
34
import com.be.sportizebe.domain.match.dto.response.MatchDetailResponse;
5+
import com.be.sportizebe.domain.match.dto.response.MatchNearResponse;
46
import com.be.sportizebe.domain.match.dto.response.MatchResponse;
57
import com.be.sportizebe.domain.match.entity.MatchParticipant;
68
import com.be.sportizebe.domain.match.entity.MatchParticipantStatus;
@@ -16,6 +18,7 @@
1618
import org.springframework.stereotype.Service;
1719
import org.springframework.transaction.annotation.Transactional;
1820
import com.be.sportizebe.domain.match.dto.request.MatchCreateRequest;
21+
import java.util.List;
1922

2023
@Service
2124
@RequiredArgsConstructor
@@ -88,4 +91,18 @@ public MatchDetailResponse getMatchDetail(Long matchId, Long userId) {
8891
// 2) 응답 DTO 생성 (matchRoom + user 기준 정보 포함)
8992
return MatchDetailResponse.of(matchRoom, user);
9093
}
94+
95+
@Override
96+
@Transactional(readOnly = true)
97+
public List<MatchNearResponse> getNearMatches(MatchNearRequest request) {
98+
String sportsName = request.getSportsName() == null
99+
? null : request.getSportsName().name();
100+
101+
return matchRoomRepository.findNear(
102+
request.getLat(), request.getLng(),
103+
request.getRadiusM(), sportsName)
104+
.stream()
105+
.map(MatchNearResponse::from)
106+
.toList();
107+
}
91108
}

src/main/java/com/be/sportizebe/domain/user/entity/SportType.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@
22

33
public enum SportType {
44
SOCCER,
5-
BASKETBALL
5+
BASKETBALL,
6+
BADMINTON,
7+
68
}

0 commit comments

Comments
 (0)