Skip to content

Commit 754b8b8

Browse files
authored
Merge pull request #15 from Nexters/feature/14-real-time-weather-updates
[#14] 실시간 날씨 업데이트 batch
2 parents 7e273d5 + b9aba35 commit 754b8b8

File tree

8 files changed

+186
-7
lines changed

8 files changed

+186
-7
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies {
2626
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
2727
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
2828
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")
29+
implementation("org.springframework.boot:spring-boot-starter-quartz")
2930
runtimeOnly("com.mysql:mysql-connector-j")
3031

3132
testImplementation("org.mockito:mockito-core:4.11.0")

src/main/kotlin/nexters/weski/WeskiApplication.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package nexters.weski
22

33
import org.springframework.boot.autoconfigure.SpringBootApplication
44
import org.springframework.boot.runApplication
5+
import org.springframework.scheduling.annotation.EnableScheduling
56

67
@SpringBootApplication(scanBasePackages = ["nexters.weski"])
8+
@EnableScheduling
79
class WeskiApplication
810

911
fun main(args: Array<String>) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package nexters.weski.batch
2+
3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import jakarta.transaction.Transactional
5+
import nexters.weski.ski_resort.SkiResort
6+
import nexters.weski.ski_resort.SkiResortRepository
7+
import nexters.weski.weather.CurrentWeather
8+
import nexters.weski.weather.CurrentWeatherRepository
9+
import org.springframework.beans.factory.annotation.Value
10+
import org.springframework.stereotype.Service
11+
import org.springframework.web.client.RestTemplate
12+
import java.time.LocalDateTime
13+
import java.time.format.DateTimeFormatter
14+
import kotlin.math.pow
15+
16+
@Service
17+
class ExternalWeatherService(
18+
private val currentWeatherRepository: CurrentWeatherRepository,
19+
private val skiResortRepository: SkiResortRepository
20+
) {
21+
@Value("\${weather.api.key}")
22+
lateinit var apiKey: String
23+
24+
val restTemplate = RestTemplate()
25+
val objectMapper = jacksonObjectMapper()
26+
27+
@Transactional
28+
fun updateCurrentWeather() {
29+
val baseDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
30+
val baseTime = getBaseTime()
31+
skiResortRepository.findAll().forEach { resort ->
32+
val url = "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getUltraSrtNcst" +
33+
"?serviceKey=$apiKey" +
34+
"&pageNo=1" +
35+
"&numOfRows=1000" +
36+
"&dataType=JSON" +
37+
"&base_date=$baseDate" +
38+
"&base_time=$baseTime" +
39+
"&nx=${resort.xCoordinate}" +
40+
"&ny=${resort.yCoordinate}"
41+
val response = restTemplate.getForObject(url, String::class.java)
42+
val weatherData = parseWeatherData(response)
43+
val newCurrentWeather = mapToCurrentWeather(weatherData, resort)
44+
45+
// 기존 데이터 조회
46+
val existingWeather = currentWeatherRepository.findBySkiResortResortId(resort.resortId)
47+
48+
if (existingWeather != null) {
49+
// 기존 데이터의 ID를 사용하여 새로운 엔티티 생성
50+
val updatedWeather = newCurrentWeather.copy(id = existingWeather.id)
51+
currentWeatherRepository.save(updatedWeather)
52+
} else {
53+
// 새로운 데이터 삽입
54+
currentWeatherRepository.save(newCurrentWeather)
55+
}
56+
}
57+
}
58+
59+
private fun getBaseTime(): String {
60+
val now = LocalDateTime.now().minusHours(1)
61+
val hour = now.hour.toString().padStart(2, '0')
62+
return "${hour}00"
63+
}
64+
65+
private fun parseWeatherData(response: String?): Map<String, String> {
66+
val data = mutableMapOf<String, String>()
67+
68+
response?.let {
69+
val rootNode = objectMapper.readTree(it)
70+
val items = rootNode["response"]["body"]["items"]["item"]
71+
72+
items.forEach { item ->
73+
val category = item["category"].asText()
74+
val value = item["obsrValue"].asText()
75+
data[category] = value
76+
}
77+
}
78+
79+
return data
80+
}
81+
82+
private fun mapToCurrentWeather(
83+
data: Map<String, String>,
84+
resort: SkiResort
85+
): CurrentWeather {
86+
val temperature = data["T1H"]?.toDoubleOrNull()?.toInt() ?: 0
87+
val windSpeed = data["WSD"]?.toDoubleOrNull() ?: 0.0
88+
val feelsLike = calculateFeelsLike(temperature, windSpeed)
89+
val condition = determineCondition(data)
90+
val description = generateDescription(condition, temperature)
91+
92+
return CurrentWeather(
93+
temperature = temperature,
94+
maxTemp = data["TMX"]?.toDoubleOrNull()?.toInt() ?: temperature,
95+
minTemp = data["TMN"]?.toDoubleOrNull()?.toInt() ?: temperature,
96+
feelsLike = feelsLike,
97+
condition = condition,
98+
description = description,
99+
skiResort = resort
100+
)
101+
}
102+
103+
private fun calculateFeelsLike(temperature: Int, windSpeed: Double): Int {
104+
return if (temperature <= 10 && windSpeed >= 4.8) {
105+
val feelsLike =
106+
13.12 + 0.6215 * temperature - 11.37 * windSpeed.pow(0.16) + 0.3965 * temperature * windSpeed.pow(
107+
0.16
108+
)
109+
feelsLike.toInt()
110+
} else {
111+
temperature
112+
}
113+
}
114+
115+
private fun determineCondition(data: Map<String, String>): String {
116+
val pty = data["PTY"]?.toIntOrNull() ?: 0
117+
val sky = data["SKY"]?.toIntOrNull() ?: 1
118+
119+
return when {
120+
pty == 1 || pty == 4 -> ""
121+
pty == 2 -> "비/눈"
122+
pty == 3 -> ""
123+
sky == 1 -> "맑음"
124+
sky == 3 -> "구름많음"
125+
sky == 4 -> "흐림"
126+
else -> "맑음"
127+
}
128+
}
129+
130+
private fun generateDescription(condition: String, temperature: Int): String {
131+
val prefix = when (condition) {
132+
"맑음" -> "화창하고"
133+
"구름많음" -> "구름이 많고"
134+
"흐림" -> "흐리고"
135+
"" -> "비가 오고"
136+
"비/눈" -> "눈비가 내리고"
137+
"" -> "눈이 오고"
138+
else -> ""
139+
}
140+
141+
val postfix = when {
142+
temperature <= -15 -> "매우 추워요"
143+
temperature in -14..-10 -> "다소 추워요"
144+
temperature in -9..-5 -> "적당한 온도에요"
145+
temperature in -4..0 -> "조금 따뜻해요"
146+
temperature in 1..5 -> "따뜻해요"
147+
temperature in 6..10 -> "다소 더워요"
148+
else -> "더워요"
149+
}
150+
151+
return "$prefix $postfix"
152+
}
153+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package nexters.weski.batch
2+
3+
import org.springframework.scheduling.annotation.Scheduled
4+
import org.springframework.stereotype.Component
5+
6+
@Component
7+
class WeatherScheduler(
8+
private val externalWeatherService: ExternalWeatherService
9+
) {
10+
@Scheduled(cron = "0 0 * * * ?")
11+
fun scheduleWeatherUpdate() {
12+
externalWeatherService.updateCurrentWeather()
13+
}
14+
}

src/main/kotlin/nexters/weski/ski_resort/SkiResort.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import jakarta.persistence.*
44
import nexters.weski.common.BaseEntity
55
import nexters.weski.slope.Slope
66
import nexters.weski.webcam.Webcam
7+
import java.time.LocalDate
78

89
@Entity
910
@Table(name = "ski_resorts")
@@ -17,9 +18,9 @@ data class SkiResort(
1718
@Enumerated(EnumType.STRING)
1819
val status: ResortStatus,
1920

20-
val openingDate: java.time.LocalDate? = null,
21+
val openingDate: LocalDate? = null,
2122

22-
val closingDate: java.time.LocalDate? = null,
23+
val closingDate: LocalDate? = null,
2324

2425
val openSlopes: Int = 0,
2526

@@ -30,6 +31,9 @@ data class SkiResort(
3031
val lateNightOperatingHours: String? = null,
3132
val dawnOperatingHours: String? = null,
3233
val midnightOperatingHours: String? = null,
34+
val snowfallTime: String? = null,
35+
val xCoordinate: String,
36+
val yCoordinate: String,
3337

3438
@OneToMany(mappedBy = "skiResort")
3539
val slopes: List<Slope> = emptyList(),

src/main/kotlin/nexters/weski/weather/CurrentWeather.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,19 @@ import nexters.weski.ski_resort.SkiResort
77
@Entity
88
@Table(name = "current_weather")
99
data class CurrentWeather(
10-
@Id
11-
val resortId: Long,
10+
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
11+
val id: Long = 0,
1212

1313
val temperature: Int,
1414
val maxTemp: Int,
1515
val minTemp: Int,
1616
val feelsLike: Int,
1717
val description: String,
18+
19+
@Column(name = "`condition`")
1820
val condition: String,
1921

2022
@OneToOne
21-
@MapsId
22-
@JoinColumn(name = "resort_id")
23+
@JoinColumn(name = "resort_id", unique = true)
2324
val skiResort: SkiResort
2425
) : BaseEntity()

src/main/kotlin/nexters/weski/weather/WeatherDto.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ data class WeatherDto(
1515
dailyWeather: List<DailyWeather>
1616
): WeatherDto {
1717
return WeatherDto(
18-
resortId = currentWeather.resortId,
18+
resortId = currentWeather.skiResort.resortId,
1919
currentWeather = CurrentWeatherDto.fromEntity(currentWeather),
2020
hourlyWeather = hourlyWeather.map { HourlyWeatherDto.fromEntity(it) },
2121
weeklyWeather = dailyWeather.map { DailyWeatherDto.fromEntity(it) }

src/main/resources/application.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ springdoc:
2121
path: /v3/api-docs
2222
swagger-ui:
2323
path: /swagger-ui.html
24+
weather:
25+
api:
26+
key: p6zNXOJrrBY4cuX7OYtdDMtmR8hiGeUaBLf0z6BXnm/qniV8wB0SuPwBgqKDTKV/24EW7xiRY3DCS21Ess/42Q==
27+

0 commit comments

Comments
 (0)