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
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ public WebClient appleApiWebClient() {
.build();
}

@Bean
public WebClient weatherApiWebClient() {
return WebClient.builder()
.baseUrl("https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0")
.build();
}
Comment on lines +24 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[P2] 기상청 API Service Key 더블 인코딩(Double Encoding) 방지

공공데이터포털에서 발급받는 기상청 API의 serviceKey는 이미 인코딩된 상태로 제공되는 경우가 많습니다.

문제점

  • WebClient는 기본적으로 URI를 빌드할 때 쿼리 파라미터를 자동으로 인코딩합니다. 이로 인해 serviceKey가 이중으로 인코딩(Double Encoding)되어 기상청 API 측에서 SERVICE_KEY_IS_NOT_REGISTERED_ERROR (등록되지 않은 서비스키) 에러를 반환할 수 있습니다.

해결 방안

weatherApiWebClient 빈 등록 시 DefaultUriBuilderFactory의 인코딩 모드를 NONE으로 설정하여 이중 인코딩을 방지하는 것이 안전합니다. 기상청 API에 전달하는 다른 파라미터(날짜, 시간, 격자 좌표 등)는 단순 숫자이므로 인코딩을 하지 않아도 무방합니다.

    @Bean
    public WebClient weatherApiWebClient() {
        org.springframework.web.util.DefaultUriBuilderFactory factory = new org.springframework.web.util.DefaultUriBuilderFactory("https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0");
        factory.setEncodingMode(org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode.NONE);
        return WebClient.builder()
                .uriBuilderFactory(factory)
                .build();
    }


}
48 changes: 48 additions & 0 deletions src/main/java/com/semosan/api/common/util/GridConverter.java
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);
}
}
94 changes: 94 additions & 0 deletions src/main/java/com/semosan/api/common/weather/WeatherService.java
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[P1] 서버 시간대(Timezone) 차이로 인한 기상청 API 조회 실패 위험

LocalDateTime.now()를 사용하여 현재 시간을 가져오고 있습니다.

문제점

  • 일반적으로 클라우드 환경(AWS EC2, Docker 등)의 서버 시간대는 UTC(세계 협정시)로 설정되어 있는 경우가 많습니다.
  • 이 경우 LocalDateTime.now()는 한국 시간(KST)보다 9시간 느린 시간을 반환하므로, 기상청 API에 잘못된 base_datebase_time을 전송하게 되어 날씨 데이터를 정상적으로 조회할 수 없습니다.

해결 방안

한국 시간대(Asia/Seoul)를 명시적으로 지정하여 현재 시간을 가져오도록 수정해야 합니다.

Suggested change
LocalDateTime now = adjustBaseTime(LocalDateTime.now());
LocalDateTime now = adjustBaseTime(LocalDateTime.now(java.time.ZoneId.of("Asia/Seoul")));


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[P3] 기상청 API 응답 데이터 타입 캐스팅 예외 방지 (Defensive Programming)

기상청 API는 데이터가 없거나 에러가 발생했을 때, items 필드를 객체(Map)가 아닌 빈 문자열("")로 반환하는 일관성 없는 응답 스펙을 가지고 있습니다.

문제점

  • items가 빈 문자열("")로 반환될 경우, (Map<String, Object>) body.get("items") 코드에서 ClassCastException이 발생합니다.
  • 비록 getTemperature 메서드 전체가 try-catch로 감싸져 있어 시스템이 다운되지는 않지만, 불필요한 예외 발생 및 경고 로그가 남게 됩니다.

해결 방안

items 필드의 타입을 안전하게 검증한 후 캐스팅하도록 방어적 코드를 추가하는 것을 권장합니다.

Suggested change
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();
Object itemsObj = body.get("items");
if (!(itemsObj instanceof Map)) return Optional.empty();
Map<String, Object> items = (Map<String, Object>) itemsObj;
List<Map<String, Object>> itemList = (List<Map<String, Object>>) items.get("item");
if (itemList == null) return Optional.empty();


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
Expand Up @@ -25,6 +25,7 @@ public record HikingRecordDetailResponse(
Double ascentMeters,
Double descentMeters,
Integer calories,
Double temperature,
LocalDateTime startedAt,
LocalDateTime endedAt,
@JsonRawValue String track,
Expand Down Expand Up @@ -84,6 +85,7 @@ public static HikingRecordDetailResponse of(
record.getAscent(),
record.getDescent(),
record.getCalories(),
record.getTemperature(),
record.getStartedAt(),
record.getEndedAt(),
track,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ public class HikingRecord extends BaseEntity {
@Column(name = "ended_at")
private LocalDateTime endedAt;

@Column(name = "temperature")
private Double temperature;

@Column(name = "clive_image_url", length = 500)
private String cliveImageUrl;

Expand Down Expand Up @@ -115,6 +118,10 @@ public static HikingRecord fromTrackingSession(
.build();
}

public void updateTemperature(Double temperature) {
this.temperature = temperature;
}

private static int computeDurationSeconds(TrackingSession session) {
if (session.getStartedAt() == null || session.getEndedAt() == null) {
return 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,6 +46,7 @@ public class TrackingSessionService {
private final HikingMemberRepository hikingMemberRepository;
private final TrackingMilestoneTriggerService milestoneTriggerService;
private final ApplicationEventPublisher eventPublisher;
private final WeatherService weatherService;

/**
* 세션 생성. 유저당 진행 중 세션은 1개만 허용한다.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

[P1] DB 트랜잭션 내 외부 API 호출로 인한 커넥션 점유 위험

complete 메서드는 @Transactional 어노테이션이 적용되어 있어 데이터베이스 트랜잭션 내에서 실행됩니다. 이 안에서 weatherService.getTemperature를 호출하여 외부 기상청 API와 동기식(blocking)으로 통신하고 있습니다.

문제점

  • 외부 API 호출에 최대 3초의 타임아웃이 설정되어 있지만, 이 시간 동안 데이터베이스 커넥션을 계속 점유하게 됩니다.
  • 트래픽이 몰리거나 기상청 API의 응답이 지연될 경우, DB 커넥션 풀 고갈(Connection Pool Exhaustion)로 이어져 전체 시스템 장애를 유발할 수 있습니다.

해결 방안

기온 정보는 등산 기록의 핵심 비즈니스 로직이 아니므로, 메인 트랜잭션 외부에서 처리하는 것이 안전합니다.

  • 대안 1: 세션 완료 트랜잭션이 성공적으로 커밋된 후, 비동기 이벤트 리스너(@Async@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT))를 통해 기온을 조회하고 HikingRecord를 업데이트합니다.
  • 대안 2: complete 메서드 진입 시점에 트랜잭션 없이 기온을 먼저 조회한 후, 조회된 기온을 트랜잭션이 적용된 내부 메서드에 전달하여 저장합니다.
References
  1. 트랜잭션 경계 설정 및 외부 사이드 이펙트 호출 시 메인 비즈니스 로직에 미치는 영향(롤백 및 커넥션 점유 등)을 신중하게 검토해야 합니다. (link)


HikingRecord savedRecord = hikingRecordRepository.save(record);

HikingMember member = HikingMember.create(savedRecord, session.getUser());
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ management:
export:
enabled: true

weather:
api-key: ${WEATHER_API_KEY}

discord:
alert:
enabled: ${DISCORD_ALERT_ENABLED}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE hiking_records
ADD COLUMN temperature DOUBLE PRECISION;