-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 등산 기록 상세 조회에 등산 당시 기온 정보 추가 #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package com.semosan.api.common.util; | ||
|
|
||
| public class GridConverter { | ||
|
|
||
| private static final double RE = 6371.00877; | ||
| private static final double GRID = 5.0; | ||
| private static final double SLAT1 = 30.0; | ||
| private static final double SLAT2 = 60.0; | ||
| private static final double OLON = 126.0; | ||
| private static final double OLAT = 38.0; | ||
| private static final double XO = 43; | ||
| private static final double YO = 136; | ||
|
|
||
| private GridConverter() { | ||
| } | ||
|
|
||
| public record Grid(int nx, int ny) { | ||
| } | ||
|
|
||
| public static Grid toGrid(double lat, double lon) { | ||
| double degrad = Math.PI / 180.0; | ||
|
|
||
| double re = RE / GRID; | ||
| double slat1 = SLAT1 * degrad; | ||
| double slat2 = SLAT2 * degrad; | ||
| double olon = OLON * degrad; | ||
| double olat = OLAT * degrad; | ||
|
|
||
| double sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); | ||
| sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); | ||
| double sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); | ||
| sf = Math.pow(sf, sn) * Math.cos(slat1) / sn; | ||
| double ro = Math.tan(Math.PI * 0.25 + olat * 0.5); | ||
| ro = re * sf / Math.pow(ro, sn); | ||
|
|
||
| double ra = Math.tan(Math.PI * 0.25 + lat * degrad * 0.5); | ||
| ra = re * sf / Math.pow(ra, sn); | ||
| double theta = lon * degrad - olon; | ||
| if (theta > Math.PI) theta -= 2.0 * Math.PI; | ||
| if (theta < -Math.PI) theta += 2.0 * Math.PI; | ||
| theta *= sn; | ||
|
|
||
| int nx = (int) Math.floor(ra * Math.sin(theta) + XO + 0.5); | ||
| int ny = (int) Math.floor(ro - ra * Math.cos(theta) + YO + 0.5); | ||
|
|
||
| return new Grid(nx, ny); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,94 @@ | ||||||||||||||||||||||||
| package com.semosan.api.common.weather; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import com.semosan.api.common.util.GridConverter; | ||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||
| import org.springframework.beans.factory.annotation.Qualifier; | ||||||||||||||||||||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||
| import org.springframework.web.reactive.function.client.WebClient; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import java.time.Duration; | ||||||||||||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||
| public class WeatherService { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd"); | ||||||||||||||||||||||||
| private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HHmm"); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| private final WebClient webClient; | ||||||||||||||||||||||||
| private final String apiKey; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| public WeatherService( | ||||||||||||||||||||||||
| @Qualifier("weatherApiWebClient") WebClient webClient, | ||||||||||||||||||||||||
| @Value("${weather.api-key}") String apiKey | ||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||
| this.webClient = webClient; | ||||||||||||||||||||||||
| this.apiKey = apiKey; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| public Optional<Double> getTemperature(double latitude, double longitude) { | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| GridConverter.Grid grid = GridConverter.toGrid(latitude, longitude); | ||||||||||||||||||||||||
| LocalDateTime now = adjustBaseTime(LocalDateTime.now()); | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] 서버 시간대(Timezone) 차이로 인한 기상청 API 조회 실패 위험
문제점
해결 방안한국 시간대(
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| String baseDate = now.format(DATE_FMT); | ||||||||||||||||||||||||
| String baseTime = now.format(TIME_FMT); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Map<String, Object> response = webClient.get() | ||||||||||||||||||||||||
| .uri(uriBuilder -> uriBuilder | ||||||||||||||||||||||||
| .path("/getUltraSrtNcst") | ||||||||||||||||||||||||
| .queryParam("serviceKey", apiKey) | ||||||||||||||||||||||||
| .queryParam("pageNo", 1) | ||||||||||||||||||||||||
| .queryParam("numOfRows", 10) | ||||||||||||||||||||||||
| .queryParam("dataType", "JSON") | ||||||||||||||||||||||||
| .queryParam("base_date", baseDate) | ||||||||||||||||||||||||
| .queryParam("base_time", baseTime) | ||||||||||||||||||||||||
| .queryParam("nx", grid.nx()) | ||||||||||||||||||||||||
| .queryParam("ny", grid.ny()) | ||||||||||||||||||||||||
| .build()) | ||||||||||||||||||||||||
| .retrieve() | ||||||||||||||||||||||||
| .bodyToMono(Map.class) | ||||||||||||||||||||||||
| .block(Duration.ofSeconds(3)); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return extractTemperature(response); | ||||||||||||||||||||||||
| } catch (Exception e) { | ||||||||||||||||||||||||
| log.warn("기상청 API 호출 실패: {}", e.getMessage()); | ||||||||||||||||||||||||
| return Optional.empty(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @SuppressWarnings("unchecked") | ||||||||||||||||||||||||
| private Optional<Double> extractTemperature(Map<String, Object> response) { | ||||||||||||||||||||||||
| if (response == null) return Optional.empty(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Map<String, Object> root = (Map<String, Object>) response.get("response"); | ||||||||||||||||||||||||
| if (root == null) return Optional.empty(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Map<String, Object> body = (Map<String, Object>) root.get("body"); | ||||||||||||||||||||||||
| if (body == null) return Optional.empty(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Map<String, Object> items = (Map<String, Object>) body.get("items"); | ||||||||||||||||||||||||
| if (items == null) return Optional.empty(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| List<Map<String, Object>> itemList = (List<Map<String, Object>>) items.get("item"); | ||||||||||||||||||||||||
| if (itemList == null) return Optional.empty(); | ||||||||||||||||||||||||
|
Comment on lines
+76
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] 기상청 API 응답 데이터 타입 캐스팅 예외 방지 (Defensive Programming)기상청 API는 데이터가 없거나 에러가 발생했을 때, 문제점
해결 방안
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return itemList.stream() | ||||||||||||||||||||||||
| .filter(item -> "T1H".equals(item.get("category"))) | ||||||||||||||||||||||||
| .findFirst() | ||||||||||||||||||||||||
| .map(item -> Double.parseDouble(String.valueOf(item.get("obsrValue")))); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| private LocalDateTime adjustBaseTime(LocalDateTime now) { | ||||||||||||||||||||||||
| if (now.getMinute() < 40) { | ||||||||||||||||||||||||
| now = now.minusHours(1); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return now.withMinute(0).withSecond(0).withNano(0); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import com.semosan.api.common.exception.ConstraintViolationUtils; | ||
| import com.semosan.api.domain.user.entity.User; | ||
| import com.semosan.api.domain.user.service.UserReader; | ||
| import com.semosan.api.common.weather.WeatherService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.context.ApplicationEventPublisher; | ||
|
|
@@ -45,6 +46,7 @@ public class TrackingSessionService { | |
| private final HikingMemberRepository hikingMemberRepository; | ||
| private final TrackingMilestoneTriggerService milestoneTriggerService; | ||
| private final ApplicationEventPublisher eventPublisher; | ||
| private final WeatherService weatherService; | ||
|
|
||
| /** | ||
| * 세션 생성. 유저당 진행 중 세션은 1개만 허용한다. | ||
|
|
@@ -128,6 +130,12 @@ public TrackingSessionResponse complete(Long userId, Long sessionId) { | |
| stats.ascentMeters(), | ||
| stats.descentMeters() | ||
| ); | ||
| Mountain mountain = session.getMountain(); | ||
| if (mountain.getLatitude() != null && mountain.getLongitude() != null) { | ||
| weatherService.getTemperature(mountain.getLatitude(), mountain.getLongitude()) | ||
| .ifPresent(record::updateTemperature); | ||
| } | ||
|
Comment on lines
+133
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] DB 트랜잭션 내 외부 API 호출로 인한 커넥션 점유 위험
문제점
해결 방안기온 정보는 등산 기록의 핵심 비즈니스 로직이 아니므로, 메인 트랜잭션 외부에서 처리하는 것이 안전합니다.
References
|
||
|
|
||
| HikingRecord savedRecord = hikingRecordRepository.save(record); | ||
|
|
||
| HikingMember member = HikingMember.create(savedRecord, session.getUser()); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| ALTER TABLE hiking_records | ||
| ADD COLUMN temperature DOUBLE PRECISION; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] 기상청 API Service Key 더블 인코딩(Double Encoding) 방지
공공데이터포털에서 발급받는 기상청 API의
serviceKey는 이미 인코딩된 상태로 제공되는 경우가 많습니다.문제점
WebClient는 기본적으로 URI를 빌드할 때 쿼리 파라미터를 자동으로 인코딩합니다. 이로 인해serviceKey가 이중으로 인코딩(Double Encoding)되어 기상청 API 측에서SERVICE_KEY_IS_NOT_REGISTERED_ERROR(등록되지 않은 서비스키) 에러를 반환할 수 있습니다.해결 방안
weatherApiWebClient빈 등록 시DefaultUriBuilderFactory의 인코딩 모드를NONE으로 설정하여 이중 인코딩을 방지하는 것이 안전합니다. 기상청 API에 전달하는 다른 파라미터(날짜, 시간, 격자 좌표 등)는 단순 숫자이므로 인코딩을 하지 않아도 무방합니다.