From eecb130b75f1f60dcb47a3e3fa6acdb0fc340183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Sun, 25 Jan 2026 23:58:32 +0900 Subject: [PATCH 1/3] =?UTF-8?q?:sparkles:Feat:=20=EC=B2=B4=EC=9C=A1?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=8F=20?= =?UTF-8?q?GSI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ build.gradle | 7 ++++ .../facility/entity/SportsFacility.java | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java diff --git a/.gitignore b/.gitignore index cafe8bf..d24fcb6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ src/main/resources/*.yml ### VS Code ### .vscode/ + +## local docker compose +docker/docker-compose.local.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index dc4806f..d2f971c 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,13 @@ dependencies { // AWS S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // PostGIS 공간 타입 <-> JTS(Point 등) 매핑 지원 + implementation "org.hibernate.orm:hibernate-spatial:6.4.8.Final" + + // JTS + implementation 'org.locationtech.jts:jts-core:1.19.0' + } tasks.named('test') { diff --git a/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java new file mode 100644 index 0000000..b769c97 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java @@ -0,0 +1,40 @@ +package com.be.sportizebe.domain.facility.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.locationtech.jts.geom.Point; + +@Entity +@Table(name = "sports_facilities") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class SportsFacility { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String facilityName; + + @Column(columnDefinition = "text") + private String introduce; + + // 리소스 부담 생각해서 일단 썸네일 이미지 1장으로만 구현하는 방식 + private String thumbnailUrl; + + @Column(columnDefinition = "geography(Point, 4326)", nullable = false) + private Point location; + + // 변경 메서드(Dirty Checking용) + public void changeInfo(String facilityName, String introduce, String thumbnailUrl) { + if (facilityName != null) this.facilityName = facilityName; + if (introduce != null) this.introduce = introduce; + if (thumbnailUrl != null) this.thumbnailUrl = thumbnailUrl; + } + + public void changeLocation(Point location) { + this.location = location; + } +} From 98919ff102eca310d688d82de004d67b4e6224fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Mon, 26 Jan 2026 16:05:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?:sparkles:Feat:=20PostGIS=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B2=94=EC=9C=84=20=EB=82=B4=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=B2=B4=EC=9C=A1=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../controller/SportsFacilityController.java | 26 +++++++++++++ .../facility/dto/FacilityNearResponse.java | 14 +++++++ .../facility/entity/SportsFacility.java | 5 ++- .../repository/FacilityNearProjection.java | 9 +++++ .../repository/SportsFacilityRepository.java | 36 ++++++++++++++++++ .../service/SportsFacilityService.java | 38 +++++++++++++++++++ 7 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java diff --git a/build.gradle b/build.gradle index d2f971c..7e4463b 100644 --- a/build.gradle +++ b/build.gradle @@ -52,7 +52,7 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' // PostGIS 공간 타입 <-> JTS(Point 등) 매핑 지원 - implementation "org.hibernate.orm:hibernate-spatial:6.4.8.Final" + implementation "org.hibernate.orm:hibernate-spatial" // JTS implementation 'org.locationtech.jts:jts-core:1.19.0' diff --git a/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java new file mode 100644 index 0000000..28f7890 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java @@ -0,0 +1,26 @@ +package com.be.sportizebe.domain.facility.controller; + +import com.be.sportizebe.domain.facility.dto.FacilityNearResponse; +import com.be.sportizebe.domain.facility.service.SportsFacilityService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/facilities") +public class SportsFacilityController { + + private final SportsFacilityService sportsFacilityService; + + @GetMapping("/near") + public List near( + @RequestParam double lat, + @RequestParam double lng, + @RequestParam(defaultValue = "3000") int radiusM, + @RequestParam(defaultValue = "50") int limit + ) { + return sportsFacilityService.getNear(lat, lng, radiusM, limit); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java b/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java new file mode 100644 index 0000000..b64ebd9 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java @@ -0,0 +1,14 @@ +package com.be.sportizebe.domain.facility.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FacilityNearResponse { + private Long id; + private String facilityName; + private String introduce; + private String thumbnailUrl; + private int distanceM; // 프론트에서 보기 쉽게 미터 int로 +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java index b769c97..4eebc1a 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java +++ b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java @@ -1,5 +1,6 @@ package com.be.sportizebe.domain.facility.entity; +import com.be.sportizebe.global.common.BaseTimeEntity; import jakarta.persistence.*; import lombok.*; import org.locationtech.jts.geom.Point; @@ -8,9 +9,9 @@ @Table(name = "sports_facilities") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder -public class SportsFacility { +public class SportsFacility extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java b/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java new file mode 100644 index 0000000..51e2409 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java @@ -0,0 +1,9 @@ +package com.be.sportizebe.domain.facility.repository; + +public interface FacilityNearProjection { + Long getId(); + String getFacilityName(); + String getIntroduce(); + String getThumbnailUrl(); + Double getDistanceM(); +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java new file mode 100644 index 0000000..aa55c14 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java @@ -0,0 +1,36 @@ +package com.be.sportizebe.domain.facility.repository; + +import com.be.sportizebe.domain.facility.entity.SportsFacility; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface SportsFacilityRepository extends JpaRepository { + @Query(value = """ + SELECT + sf.id AS id, + sf.facility_name AS facilityName, + sf.introduce AS introduce, + sf.thumbnail_url AS thumbnailUrl, + ST_Distance( + sf.location, + ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography + ) AS distanceM + FROM sports_facilities sf + WHERE ST_DWithin( + sf.location, + ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, + :radiusM + ) + ORDER BY distanceM ASC + LIMIT :limit + """, nativeQuery = true) + List findNear( + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radiusM") int radiusM, + @Param("limit") int limit + ); +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java new file mode 100644 index 0000000..16afe8d --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java @@ -0,0 +1,38 @@ +package com.be.sportizebe.domain.facility.service; + +import com.be.sportizebe.domain.facility.dto.FacilityNearResponse; +import com.be.sportizebe.domain.facility.repository.FacilityNearProjection; +import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SportsFacilityService { + private final SportsFacilityRepository sportsFacilityRepository; + + public List getNear(double lat, double lng, int radiusM, int limit) { + if (radiusM <= 0) radiusM = 1000; + if (radiusM > 20000) radiusM = 20000; // 너무 큰 반경 제한 + if (limit <= 0) limit = 50; + if (limit > 200) limit = 200; + + List rows = + sportsFacilityRepository.findNear(lat, lng, radiusM, limit); + + return rows.stream() + .map(r -> FacilityNearResponse.builder() + .id(r.getId()) + .facilityName(r.getFacilityName()) + .introduce(r.getIntroduce()) + .thumbnailUrl(r.getThumbnailUrl()) + .distanceM((int)Math.round(r.getDistanceM())) + .build() + ) + .toList(); + } +} From 631069c834de2f92aa94284f03f4e9ec9eae0e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=ED=9B=88=EA=B8=B0?= Date: Mon, 26 Jan 2026 23:59:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?:recycle:Refactor:=20FacilityType=20enum=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SportsFacilityController.java | 6 +++-- .../facility/dto/FacilityNearResponse.java | 1 + .../domain/facility/entity/FacilityType.java | 10 ++++++++ .../facility/entity/SportsFacility.java | 7 +++++- .../facility/mapper/FacilityMapper.java | 18 +++++++++++++++ .../repository/FacilityNearProjection.java | 1 + .../repository/SportsFacilityRepository.java | 12 ++++++---- .../service/SportsFacilityService.java | 23 ++++++++++--------- 8 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/be/sportizebe/domain/facility/entity/FacilityType.java create mode 100644 src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java diff --git a/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java index 28f7890..a413b7e 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java +++ b/src/main/java/com/be/sportizebe/domain/facility/controller/SportsFacilityController.java @@ -19,8 +19,10 @@ public List near( @RequestParam double lat, @RequestParam double lng, @RequestParam(defaultValue = "3000") int radiusM, - @RequestParam(defaultValue = "50") int limit + @RequestParam(defaultValue = "50") int limit, + @RequestParam(required = false) String type + ) { - return sportsFacilityService.getNear(lat, lng, radiusM, limit); + return sportsFacilityService.getNear(lat, lng, radiusM, limit, type); } } \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java b/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java index b64ebd9..eeaab85 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java +++ b/src/main/java/com/be/sportizebe/domain/facility/dto/FacilityNearResponse.java @@ -10,5 +10,6 @@ public class FacilityNearResponse { private String facilityName; private String introduce; private String thumbnailUrl; + private String facilityType; private int distanceM; // 프론트에서 보기 쉽게 미터 int로 } diff --git a/src/main/java/com/be/sportizebe/domain/facility/entity/FacilityType.java b/src/main/java/com/be/sportizebe/domain/facility/entity/FacilityType.java new file mode 100644 index 0000000..5835429 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/entity/FacilityType.java @@ -0,0 +1,10 @@ +package com.be.sportizebe.domain.facility.entity; + +public enum FacilityType { + BASKETBALL, + SOCCER, + BADMINTON, + TENNIS, + BOWLING, + ETC +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java index 4eebc1a..059c90e 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java +++ b/src/main/java/com/be/sportizebe/domain/facility/entity/SportsFacility.java @@ -25,14 +25,19 @@ public class SportsFacility extends BaseTimeEntity { // 리소스 부담 생각해서 일단 썸네일 이미지 1장으로만 구현하는 방식 private String thumbnailUrl; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private FacilityType facilityType; + @Column(columnDefinition = "geography(Point, 4326)", nullable = false) private Point location; // 변경 메서드(Dirty Checking용) - public void changeInfo(String facilityName, String introduce, String thumbnailUrl) { + public void changeInfo(String facilityName, String introduce, String thumbnailUrl, FacilityType facilityType) { if (facilityName != null) this.facilityName = facilityName; if (introduce != null) this.introduce = introduce; if (thumbnailUrl != null) this.thumbnailUrl = thumbnailUrl; + if (facilityType != null) this.facilityType = facilityType; } public void changeLocation(Point location) { diff --git a/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java b/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java new file mode 100644 index 0000000..c7b31d3 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/facility/mapper/FacilityMapper.java @@ -0,0 +1,18 @@ +package com.be.sportizebe.domain.facility.mapper; + +import com.be.sportizebe.domain.facility.dto.FacilityNearResponse; +import com.be.sportizebe.domain.facility.repository.FacilityNearProjection; + +public interface FacilityMapper { + + public static FacilityNearResponse toNearResponse(FacilityNearProjection p){ + return FacilityNearResponse.builder() + .id(p.getId()) + .facilityName(p.getFacilityName()) + .introduce(p.getIntroduce()) + .thumbnailUrl(p.getThumbnailUrl()) + .facilityType(p.getFacilityType()) + .distanceM((int) Math.round(p.getDistanceM())) + .build(); + } +} diff --git a/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java b/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java index 51e2409..0a86994 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java +++ b/src/main/java/com/be/sportizebe/domain/facility/repository/FacilityNearProjection.java @@ -4,6 +4,7 @@ public interface FacilityNearProjection { Long getId(); String getFacilityName(); String getIntroduce(); + String getFacilityType(); String getThumbnailUrl(); Double getDistanceM(); } diff --git a/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java index aa55c14..41afe5a 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java +++ b/src/main/java/com/be/sportizebe/domain/facility/repository/SportsFacilityRepository.java @@ -8,12 +8,14 @@ import java.util.List; public interface SportsFacilityRepository extends JpaRepository { + @Query(value = """ SELECT sf.id AS id, sf.facility_name AS facilityName, sf.introduce AS introduce, sf.thumbnail_url AS thumbnailUrl, + sf.facility_type AS facilityType, ST_Distance( sf.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography @@ -24,13 +26,15 @@ WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :radiusM ) - ORDER BY distanceM ASC + AND (:type IS NULL OR sf.facility_type = :type) + ORDER BY distanceM LIMIT :limit """, nativeQuery = true) List findNear( @Param("lat") double lat, @Param("lng") double lng, - @Param("radiusM") int radiusM, - @Param("limit") int limit + @Param("radiusM") long radiusM, + @Param("limit") int limit, + @Param("type") String type ); -} +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java index 16afe8d..4c1e1fb 100644 --- a/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java +++ b/src/main/java/com/be/sportizebe/domain/facility/service/SportsFacilityService.java @@ -1,6 +1,7 @@ package com.be.sportizebe.domain.facility.service; import com.be.sportizebe.domain.facility.dto.FacilityNearResponse; +import com.be.sportizebe.domain.facility.mapper.FacilityMapper; import com.be.sportizebe.domain.facility.repository.FacilityNearProjection; import com.be.sportizebe.domain.facility.repository.SportsFacilityRepository; import lombok.RequiredArgsConstructor; @@ -13,26 +14,26 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class SportsFacilityService { + private final SportsFacilityRepository sportsFacilityRepository; - public List getNear(double lat, double lng, int radiusM, int limit) { + public List getNear( + double lat, + double lng, + long radiusM, + int limit, + String type + ) { if (radiusM <= 0) radiusM = 1000; - if (radiusM > 20000) radiusM = 20000; // 너무 큰 반경 제한 + if (radiusM > 20000) radiusM = 20000; if (limit <= 0) limit = 50; if (limit > 200) limit = 200; List rows = - sportsFacilityRepository.findNear(lat, lng, radiusM, limit); + sportsFacilityRepository.findNear(lat, lng, radiusM, limit, type); return rows.stream() - .map(r -> FacilityNearResponse.builder() - .id(r.getId()) - .facilityName(r.getFacilityName()) - .introduce(r.getIntroduce()) - .thumbnailUrl(r.getThumbnailUrl()) - .distanceM((int)Math.round(r.getDistanceM())) - .build() - ) + .map(FacilityMapper::toNearResponse) .toList(); } }