From a79cb8868a65d0e3140901a6ceb225615a08983a Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sat, 20 Sep 2025 21:07:42 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[SCRUM-293]=20FEAT:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=A7=80=EB=8F=84=20=EC=A4=91=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EB=A1=9C=20=ED=96=89=EC=A0=95=EA=B5=AC=EC=97=AD=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EB=B3=80=ED=99=98=20API=20=EC=97=B0=EB=8F=99=20(#1?= =?UTF-8?q?40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 지도에서 제공하는 좌표로 행정구역 정보를 변환하는 API를 연동했습니다. 주소가 등록되지 않은 지역에서도 결과값을 도출할 수 있습니다. --- .../course/service/KakaoMapService.java | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java index c38f9ae..ee32a36 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java @@ -53,6 +53,7 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { // documents 안에 도로명 주소(road_address)와 지번 주소(address)가 포함되어 응답 JsonNode documents = root.path("documents"); + // 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 함 if (documents.isArray() && !documents.isEmpty()) { return documents.get(0); } @@ -68,7 +69,6 @@ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { /** * 카카오 지도 API에서 가져온 주소 정보에서 구 단위, 동 단위를 추출합니다. - * 도로명 주소(road_address)는 좌표에 따라 반환되지 않을 수 있기 때문에 지번 주소(address)를 기준으로 합니다. * * @param jsonNode 주소 정보 JSON * @return districtName, dongName으로 구성된 AddressInfo, 없으면 null @@ -84,6 +84,66 @@ public AddressInfo extractDistrictNameAndDongName(JsonNode jsonNode) { return new AddressInfo(districtName, dongName); } + /** + * 주어진 위도(latitude), 경도(longitude) 좌표로부터 카카오 지도 API를 통해 행정구역 정보를 조회합니다. + * + * @param longitude 경도 (x) + * @param latitude 위도 (y) + * @return 행정구역 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null + */ + public JsonNode getRegionCodeFromCoordinate(double longitude, double latitude) { + String requestUrl = "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json" + + "?x=" + longitude + + "&y=" + latitude; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + kakaoApiKey); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + log.info("[카카오 지도 API 호출] 요청 URL: {}", requestUrl); + + try { + ResponseEntity response = restTemplate.exchange( + requestUrl, + HttpMethod.GET, + entity, + String.class + ); + + JsonNode root = objectMapper.readTree(response.getBody()); + + // documents 안에 해당 좌표에 부합하는 행정동(H), 법정동(B) 행정구역 정보가 포함되어 응답 + JsonNode documents = root.path("documents"); + + // 법으로 지정되어 행정동(H)보다 안정적인 법정동(B)을 기준으로 함 + if (documents.isArray() && !documents.isEmpty()) { + return documents.get(0); + } + + log.warn("[카카오 지도 API 호출] 카카오 지도 API에서 행정구역 정보 없음: x={}, y={}", longitude, latitude); + return null; + + } catch (Exception e) { + log.error("[카카오 지도 API 호출] 카카오 지도 API 파싱 실패: x={}, y={}", longitude, latitude, e); + return null; + } + } + + /** + * 카카오 지도 API에서 가져온 행정구역 정보에서 시도 단위를 추출합니다. + * + * @param jsonNode 행정구역 정보 JSON + * @return provinceName, 없으면 null + */ + public String extractProvinceName(JsonNode jsonNode) { + if (jsonNode == null) { + return null; + } + + return textToNull(jsonNode.path("region_1depth_name").asText()); + } + /** * 주어진 텍스트가 Null이거나 공백 문자인 경우 Null로 반환합니다. */ From b462b91b9bb3c659986b983ec45279b0196b087a Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sat, 20 Sep 2025 21:11:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[SCRUM-293]=20FEAT:=20=EB=8C=80=ED=95=9C?= =?UTF-8?q?=EB=AF=BC=EA=B5=AD=20=ED=8C=90=EB=B3=84=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 생성한 코스의 시작점, 경유지, 도착점 좌표가 대한민국 내에 있는지 검증하여, 대한민국이 아닌 코스 데이터를 필터링합니다. 아래와 같은 방식으로 동작합니다. - 대한민국 경계 좌표를 통해 1차 필터링 - 통과한 좌표에 한해 카카오 지도 API를 통해 행정구역 정보를 조회 - 맞닿아 있는 해외에 한해 (일본, 중국, 북한 등) 응답이 옴으로 시도단위인 region_1depth_name 유무로 판별 --- .../course/controller/CourseController.java | 16 ++++++- .../domain/course/dto/CoordinateListDto.java | 14 +++++++ .../domain/course/service/CourseService.java | 42 +++++++++++++++++-- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 421cb2e..5d7d1d2 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; + import java.util.List; import lombok.RequiredArgsConstructor; @@ -182,7 +182,7 @@ public ResponseEntity> getMyCourse( return ResponseEntity.ok(CommonResponse.success(SUCCESS, myCourseDetailDto)); } - @Operation(summary = "지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") + @Operation(summary = "부산 지역 판별", description = "특정 위치 좌표가 부산 내 지역인지 판별합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공") }) @@ -291,4 +291,16 @@ public ResponseEntity imageProxy(@RequestParam String url) { } } + @Operation(summary = "대한민국 지역 판별", description = "특정 좌표 배열이 모두 대한민국 내 지역인지 판별합니다. 하나의 좌표라도 대한민국이 아닐 경우, false를 반환하며, 요청 시 좌표 배열은 시작점과 도착점을 포함하여 최소 2개 이상이어야 합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공") + }) + @PostMapping("/api/locations/korea") + public ResponseEntity> isKoreaCourse( + @Valid @RequestBody CoordinateListDto coordinateDtoList + ) { + boolean isKoreaCourse = courseService.isKoreaCourse(coordinateDtoList); + return ResponseEntity.ok(CommonResponse.success(SUCCESS, isKoreaCourse)); + } + } diff --git a/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java b/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java new file mode 100644 index 0000000..a521e75 --- /dev/null +++ b/src/main/java/com/server/running_handai/domain/course/dto/CoordinateListDto.java @@ -0,0 +1,14 @@ +package com.server.running_handai.domain.course.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record CoordinateListDto( + @NotNull(message = "좌표 배열은 null일 수 없습니다.") + @Size(min = 2, message = "좌표 배열은 시작점과 도착점을 포함하여 최소 2개 이상이어야 합니다.") + List coordinateDtoList +) { + public record CoordinateDto(double latitude, double longitude) {} +} \ No newline at end of file diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index cd7cb02..ef527d9 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -27,16 +27,15 @@ import com.server.running_handai.domain.review.service.ReviewService; import com.server.running_handai.domain.spot.dto.SpotInfoDto; import com.server.running_handai.domain.spot.repository.SpotRepository; -import com.server.running_handai.global.response.ResponseCode; import com.server.running_handai.global.response.exception.BusinessException; import java.util.*; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateList; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; @@ -254,7 +253,7 @@ public CourseSummaryDto getCourseSummary(Long courseId, Long memberId) { */ public GpxPathDto downloadGpx(Long courseId, Long memberId) { Course course = courseRepository.findByIdAndCreatorId(courseId, memberId) - .orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); // Presigned GET URL 발급 (1시간) String gpxPath = fileService.getPresignedGetUrl(course.getGpxPath(), 60); @@ -287,7 +286,7 @@ public MyAllCoursesDetailDto getMyAllCourses(Long memberId, Pageable pageable, S */ public MyCourseDetailDto getMyCourse(Long memberId, Long courseId) { Course course = courseRepository.findByIdAndCreatorIdWithTrackPoints(courseId, memberId) - .orElseThrow(() -> new BusinessException(ResponseCode.COURSE_NOT_FOUND)); + .orElseThrow(() -> new BusinessException(COURSE_NOT_FOUND)); List trackPointDtos = course.getTrackPoints().stream() .map(TrackPointDto::from) @@ -433,4 +432,39 @@ private void updateThumbnailImage(Course course, MultipartFile newImageFile) { courseDataService.updateCourseImage(course.getId(), newImageFile); } } + + /** + * 주어진 좌표 배열의 모든 좌표가 대한민국 내에 있는지 판별합니다. + * + * @param coordinateDtoList 좌표 배열 + * @return 모두 대한민국 지역 내에 있으면 true, 하나라도 아니면 false + */ + public Boolean isKoreaCourse(CoordinateListDto coordinateDtoList) { + // 모든 좌표를 순회하며 검사 + for (CoordinateListDto.CoordinateDto coordinateDto : coordinateDtoList.coordinateDtoList()) { + double latitude = coordinateDto.latitude(); + double longitude = coordinateDto.longitude(); + + // 대한민국 경계 좌표를 통해 필터링 + if (latitude < 33.0 || latitude > 38.9 || longitude < 124.5 || longitude > 132.0) { + return false; + } + + // 대한민국 경계 좌표를 통과한 좌표에 대해 카카오 지도 API를 통해 행정구역 정보를 조회 + // 행정구역 정보가 존재하지 않을 경우 false로 반환 + JsonNode regionCode = kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude); + if (regionCode == null) { + return false; + } + + // 행정구역 정보에서 시도단위 추출 + // 시도단위가 존재하지 않을 경우 false로 반환 + String provinceName = kakaoMapService.extractProvinceName(regionCode); + if (provinceName == null) { + return false; + } + } + + return true; + } } From ef1ad9e5b8d21a9ff3aeba0a72a597ddf5342b90 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Sun, 21 Sep 2025 03:02:26 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[SCRUM-293]=20TEST:=20=EB=8C=80=ED=95=9C?= =?UTF-8?q?=EB=AF=BC=EA=B5=AD=20=ED=8C=90=EB=B3=84=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대한민국 판별 API의 서비스 메서드들에 대하여 테스트 코드를 구현하고 테스트를 진행했습니다. (성공 2개) --- .../course/controller/CourseController.java | 3 +- .../course/service/CourseServiceTest.java | 146 +++++++++++++++++- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 5d7d1d2..8f06e54 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -293,7 +293,8 @@ public ResponseEntity imageProxy(@RequestParam String url) { @Operation(summary = "대한민국 지역 판별", description = "특정 좌표 배열이 모두 대한민국 내 지역인지 판별합니다. 하나의 좌표라도 대한민국이 아닐 경우, false를 반환하며, 요청 시 좌표 배열은 시작점과 도착점을 포함하여 최소 2개 이상이어야 합니다.") @ApiResponses({ - @ApiResponse(responseCode = "200", description = "성공") + @ApiResponse(responseCode = "200", description = "성공 - SUCCESS"), + @ApiResponse(responseCode = "400", description = "실패 (좌표가 2개 미만) - INVALID_INPUT_VALUE") }) @PostMapping("/api/locations/korea") public ResponseEntity> isKoreaCourse( diff --git a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java index a489b2d..f970fea 100644 --- a/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java +++ b/src/test/java/com/server/running_handai/domain/course/service/CourseServiceTest.java @@ -3,11 +3,7 @@ import static com.server.running_handai.domain.course.entity.CourseFilter.*; import static com.server.running_handai.domain.course.entity.SpotStatus.*; import static com.server.running_handai.domain.course.service.CourseService.MYSQL_POINT_FORMAT; -import static com.server.running_handai.global.response.ResponseCode.COURSE_NOT_FOUND; -import static com.server.running_handai.global.response.ResponseCode.DUPLICATE_COURSE_NAME; -import static com.server.running_handai.global.response.ResponseCode.INVALID_COURSE_NAME_PARAMETER; -import static com.server.running_handai.global.response.ResponseCode.MEMBER_NOT_FOUND; -import static com.server.running_handai.global.response.ResponseCode.NO_AUTHORITY_TO_DELETE_COURSE; +import static com.server.running_handai.global.response.ResponseCode.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -20,6 +16,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -47,6 +44,8 @@ import java.time.LocalDateTime; import java.util.*; import java.util.stream.Stream; + +import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -1267,4 +1266,141 @@ void updateCourse_fail_duplicateName() { assertThat(course.getName()).isEqualTo(originalCourseName); } } + + @Nested + @DisplayName("대한민국 판별 테스트") + class CheckKoreaTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * [대한민국 판별] 성공 + * 1. 모든 좌표가 대한민국에 속하는 경우 (true 응답) + */ + @Test + @DisplayName("대한민국 판별 성공 - true 반환") + void isKoreaCourse_success_inKorea() throws Exception { + // given + List coordinateDtoList = List.of( + new CoordinateListDto.CoordinateDto(37.566826, 126.9786567), // 서울시청 + new CoordinateListDto.CoordinateDto(35.158656, 129.160113) // 해운대 해수욕장 + ); + + CoordinateListDto coordinateListDto = new CoordinateListDto(coordinateDtoList); + + JsonNode mockRegionCode1 = createMockRegionCodeNode("서울특별시"); + JsonNode mockRegionCode2 = createMockRegionCodeNode("부산광역시"); + + given(kakaoMapService.getRegionCodeFromCoordinate(coordinateDtoList.get(0).longitude(), coordinateDtoList.get(0).latitude())).willReturn(mockRegionCode1); + given(kakaoMapService.getRegionCodeFromCoordinate(coordinateDtoList.get(1).longitude(), coordinateDtoList.get(1).latitude())).willReturn(mockRegionCode2); + given(kakaoMapService.extractProvinceName(mockRegionCode1)).willReturn("서울특별시"); + given(kakaoMapService.extractProvinceName(mockRegionCode2)).willReturn("부산광역시"); + + // when + boolean result = courseService.isKoreaCourse(coordinateListDto); + + // then + assertThat(result).isTrue(); + } + + /** + * [대한민국 판별] 성공 + * 2. 모든 좌표가 대한민국에 속하는 경우 (false 응답) + */ + @ParameterizedTest + @MethodSource("coordinateProvider") + @DisplayName("대한민국 판별 성공 - false 반환") + void isKoreaCourse_success_notInKorea(CoordinateListDto coordinateListDto, + boolean[] expectedBoundaryResults, + boolean expectedFinalResult, + String[] provinceNames + ) throws Exception { + // given + for (int i = 0; i < coordinateListDto.coordinateDtoList().size(); i++) { + // 경계 좌표 검증 + CoordinateListDto.CoordinateDto coordinateDto = coordinateListDto.coordinateDtoList().get(i); + double latitude = coordinateDto.latitude(); + double longitude = coordinateDto.longitude(); + + boolean boundary = latitude >= 33.0 && latitude <= 38.9 && longitude >= 124.5 && longitude <= 132.0; + + // then + assertThat(boundary).isEqualTo(expectedBoundaryResults[i]); + + // 카카오 지도 검증 + if (boundary) { + JsonNode mockRegionCode = null; + if (provinceNames[i] != null) { + mockRegionCode = createMockRegionCodeNode(provinceNames[i]); + + // given + given(kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude)).willReturn(mockRegionCode); + given(kakaoMapService.extractProvinceName(mockRegionCode)).willReturn(mockRegionCode.get("region_1depth_name").asText()); + } else { + lenient().when(kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude)).thenReturn(null); + } + } + } + + // when + boolean result = courseService.isKoreaCourse(coordinateListDto); + + // then + assertThat(result).isFalse(); + assertThat(result).isEqualTo(expectedFinalResult); + } + + static Stream coordinateProvider() { + return Stream.of( + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 1] 2개 중 1개만 해외 (경계 좌표에서 필터링) + new CoordinateListDto.CoordinateDto(33.499621, 126.531188), // 제주도 + new CoordinateListDto.CoordinateDto(48.858370, 2.294481) // 프랑스 파리 + )), + new boolean[]{true, false}, + false, + new String[]{"제주특별자치도", null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 2] 2개 1개만 해외 (경계 좌표 통과) + new CoordinateListDto.CoordinateDto(35.160032, 126.851338), // 광주 + new CoordinateListDto.CoordinateDto(34.2565, 129.2891) // 일본 대마도 + )), + new boolean[]{true, true}, + false, + new String[]{"광주광역시", null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 3] 2개 모두 해외 (경계 좌표에서 필터링) + new CoordinateListDto.CoordinateDto(40.689247, -74.044502), // 미국 뉴욕 + new CoordinateListDto.CoordinateDto(35.658581, 139.745433) // 일본 도쿄 + )), + new boolean[]{false, false}, + false, + new String[]{null, null} + ), + Arguments.of( + new CoordinateListDto(List.of( + // [CAES 4] 2개 모두 해외 (경계 좌표 통과) + new CoordinateListDto.CoordinateDto(34.2565, 129.2891), // 일본 대마도 + new CoordinateListDto.CoordinateDto(34.694406, 129.441962) // 일본 대마도 + )), + new boolean[]{true, true}, + false, + new String[]{null, null} + ) + ); + } + + private JsonNode createMockRegionCodeNode(String provinceName) throws Exception { + String jsonString = String.format( + "{\"region_1depth_name\": \"%s\"}", + provinceName + ); + + return objectMapper.readTree(jsonString); + } + } } \ No newline at end of file From f7e814615acaecbc28c4e05184278dca26415938 Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 23 Sep 2025 22:49:31 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[SCRUM-293]=20CHORE:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=A7=80=EB=8F=84=20=EC=9A=94=EC=B2=AD=20API=20app?= =?UTF-8?q?lication.yml=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/course/service/KakaoMapService.java | 11 ++++++++--- src/main/resources/application.yml | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java index ee32a36..1d81e55 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/KakaoMapService.java @@ -19,6 +19,12 @@ public class KakaoMapService { @Value("${spring.security.oauth2.client.registration.kakao.client-id}") private String kakaoApiKey; + @Value("${kakao.address}") + private String addressRequestUrl; + + @Value("${kakao.region-code}") + private String regionCodeRequestUrl; + public record AddressInfo(String districtName, String dongName) {} /** @@ -29,7 +35,7 @@ public record AddressInfo(String districtName, String dongName) {} * @return 주소 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null */ public JsonNode getAddressFromCoordinate(double longitude, double latitude) { - String requestUrl = "https://dapi.kakao.com/v2/local/geo/coord2address.json" + String requestUrl = addressRequestUrl + "?x=" + longitude + "&y=" + latitude + "&input_coord=WGS84"; // 좌표계 (기본값) @@ -92,7 +98,7 @@ public AddressInfo extractDistrictNameAndDongName(JsonNode jsonNode) { * @return 행정구역 정보가 담긴 JsonNode (성공 시 documents[0]), 없거나 파싱 실패시 null */ public JsonNode getRegionCodeFromCoordinate(double longitude, double latitude) { - String requestUrl = "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json" + String requestUrl = regionCodeRequestUrl + "?x=" + longitude + "&y=" + latitude; @@ -101,7 +107,6 @@ public JsonNode getRegionCodeFromCoordinate(double longitude, double latitude) { headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity entity = new HttpEntity<>(headers); - log.info("[카카오 지도 API 호출] 요청 URL: {}", requestUrl); try { ResponseEntity response = restTemplate.exchange( diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d22e89..ccf4a26 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -134,3 +134,7 @@ swagger: server: local: http://localhost:8080 prod: https://api.runninghandai.com + +kakao: + address: https://dapi.kakao.com/v2/local/geo/coord2address.json + region-code: https://dapi.kakao.com/v2/local/geo/coord2regioncode.json From a6524763fd29f5751d3e8df6c759349161ffb13c Mon Sep 17 00:00:00 2001 From: moonxxpower Date: Tue, 23 Sep 2025 22:53:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[SCRUM-293]=20REFACTOR:=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EC=A2=8C=ED=91=9C=20=EC=83=81=EC=88=98=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8=20=EB=B0=8F=20=EB=8B=A8=EC=9D=BC=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=20=ED=8C=90=EB=B3=84=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 경계좌표를 상수로 선언해 관리 편의성을 증가시키고, 단일 좌표 판별 로직을 별도 메서드로 분리하여 재사용성을 향상시켰습니다. 추가적으로 allMatch를 사용해 false가 나오면 즉시 종료하게 만들어 불필요한 호출 횟수를 줄였습니다. --- .../course/controller/CourseController.java | 2 + .../domain/course/service/CourseService.java | 68 +++++++++++++------ 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java index 8f06e54..23dd5ef 100644 --- a/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java +++ b/src/main/java/com/server/running_handai/domain/course/controller/CourseController.java @@ -19,6 +19,7 @@ import jakarta.validation.Valid; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -300,6 +301,7 @@ public ResponseEntity imageProxy(@RequestParam String url) { public ResponseEntity> isKoreaCourse( @Valid @RequestBody CoordinateListDto coordinateDtoList ) { + log.info("[대한민국 판별] {}개 좌표 검사 시작", coordinateDtoList.coordinateDtoList().size()); boolean isKoreaCourse = courseService.isKoreaCourse(coordinateDtoList); return ResponseEntity.ok(CommonResponse.success(SUCCESS, isKoreaCourse)); } diff --git a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java index ef527d9..b64c89e 100644 --- a/src/main/java/com/server/running_handai/domain/course/service/CourseService.java +++ b/src/main/java/com/server/running_handai/domain/course/service/CourseService.java @@ -30,12 +30,12 @@ import com.server.running_handai.global.response.exception.BusinessException; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.CoordinateList; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.LineString; import org.locationtech.jts.simplify.DouglasPeuckerSimplifier; @@ -54,6 +54,10 @@ public class CourseService { public static final String MYSQL_POINT_FORMAT = "POINT(%f %f)"; public static final String COURSE_NAME_DELIMITER = "-"; + private static final double SOUTH_KOREA_MIN_LATITUDE = 33.0; + private static final double SOUTH_KOREA_MAX_LATITUDE = 38.9; + private static final double SOUTH_KOREA_MIN_LONGITUDE = 124.5; + private static final double SOUTH_KOREA_MAX_LONGITUDE = 132.0; private final CourseRepository courseRepository; private final TrackPointRepository trackPointRepository; @@ -440,29 +444,49 @@ private void updateThumbnailImage(Course course, MultipartFile newImageFile) { * @return 모두 대한민국 지역 내에 있으면 true, 하나라도 아니면 false */ public Boolean isKoreaCourse(CoordinateListDto coordinateDtoList) { - // 모든 좌표를 순회하며 검사 - for (CoordinateListDto.CoordinateDto coordinateDto : coordinateDtoList.coordinateDtoList()) { - double latitude = coordinateDto.latitude(); - double longitude = coordinateDto.longitude(); - - // 대한민국 경계 좌표를 통해 필터링 - if (latitude < 33.0 || latitude > 38.9 || longitude < 124.5 || longitude > 132.0) { - return false; - } + AtomicInteger apiCallCount = new AtomicInteger(0); - // 대한민국 경계 좌표를 통과한 좌표에 대해 카카오 지도 API를 통해 행정구역 정보를 조회 - // 행정구역 정보가 존재하지 않을 경우 false로 반환 - JsonNode regionCode = kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude); - if (regionCode == null) { - return false; - } + boolean result = coordinateDtoList.coordinateDtoList().stream() + .allMatch(dto -> isCoordinateInKorea(dto, apiCallCount)); // false일 경우, 즉시 종료! - // 행정구역 정보에서 시도단위 추출 - // 시도단위가 존재하지 않을 경우 false로 반환 - String provinceName = kakaoMapService.extractProvinceName(regionCode); - if (provinceName == null) { - return false; - } + log.info("[대한민국 판별] 경계 좌표 필터링 통과해 API 호출: {}", apiCallCount.get()); + return result; + } + + /** + * 단일 좌표가 대한민국 내에 있는지 판별합니다. + * + * @param coordinateDto 검사할 좌표 DTO + * @param apiCallCount API 호출 개수 + * @return 대한민국 내에 있으면 true, 아니면 false + */ + private boolean isCoordinateInKorea( + CoordinateListDto.CoordinateDto coordinateDto, + AtomicInteger apiCallCount + ) { + double latitude = coordinateDto.latitude(); + double longitude = coordinateDto.longitude(); + + // 대한민국 경계 좌표를 통해 필터링 + if (latitude < SOUTH_KOREA_MIN_LATITUDE || latitude > SOUTH_KOREA_MAX_LATITUDE + || longitude < SOUTH_KOREA_MIN_LONGITUDE || longitude > SOUTH_KOREA_MAX_LONGITUDE) { + return false; + } + + apiCallCount.incrementAndGet(); + + // 대한민국 경계 좌표를 통과한 좌표에 대해 카카오 지도 API를 통해 행정구역 정보를 조회 + // 행정구역 정보가 존재하지 않을 경우 false로 반환 + JsonNode regionCode = kakaoMapService.getRegionCodeFromCoordinate(longitude, latitude); + if (regionCode == null) { + return false; + } + + // 행정구역 정보에서 시도단위 추출 + // 시도단위가 존재하지 않을 경우 false로 반환 + String provinceName = kakaoMapService.extractProvinceName(regionCode); + if (provinceName == null) { + return false; } return true;