From a272667525634178993de31c7306241914fbde69 Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 09:59:10 +0900 Subject: [PATCH 1/6] init --- build.gradle.kts | 6 +- src/main/java/study/ActorFilms.java | 7 ++ src/main/java/study/Application.java | 11 +++ src/main/java/study/Functions.java | 15 ++++ src/main/java/study/JokeController.java | 73 +++++++++++++++++++ .../weather/controller/WeatherController.java | 22 ++++++ .../weather/dto/ForecastResponse.java | 11 +++ .../java/sunshine/weather/model/City.java | 25 +++++++ .../sunshine/weather/model/WeatherCode.java | 40 ++++++++++ .../sunshine/weather/service/OpenMeteo.java | 40 ++++++++++ .../weather/service/WeatherService.java | 54 ++++++++++++++ src/main/resources/application.properties | 2 + .../sunshine/service/WeatherServiceTest.java | 56 ++++++++++++++ 13 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 src/main/java/study/ActorFilms.java create mode 100644 src/main/java/study/Application.java create mode 100644 src/main/java/study/Functions.java create mode 100644 src/main/java/study/JokeController.java create mode 100644 src/main/java/sunshine/weather/controller/WeatherController.java create mode 100644 src/main/java/sunshine/weather/dto/ForecastResponse.java create mode 100644 src/main/java/sunshine/weather/model/City.java create mode 100644 src/main/java/sunshine/weather/model/WeatherCode.java create mode 100644 src/main/java/sunshine/weather/service/OpenMeteo.java create mode 100644 src/main/java/sunshine/weather/service/WeatherService.java create mode 100644 src/test/java/sunshine/service/WeatherServiceTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 3f75395..a25b858 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("org.springframework.boot") version "3.3.1" - id("io.spring.dependency-management") version "1.1.5" + id("org.springframework.boot") version "3.5.8" + id("io.spring.dependency-management") version "1.1.7" kotlin("plugin.jpa") version "1.9.24" kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" @@ -28,6 +28,8 @@ dependencies { implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation(platform("org.springframework.ai:spring-ai-bom:1.1.2")) + implementation("org.springframework.ai:spring-ai-starter-model-google-genai") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/java/study/ActorFilms.java b/src/main/java/study/ActorFilms.java new file mode 100644 index 0000000..99aea85 --- /dev/null +++ b/src/main/java/study/ActorFilms.java @@ -0,0 +1,7 @@ +package study; + +import java.util.List; + +public record ActorFilms(String actor, List movies) { + +} diff --git a/src/main/java/study/Application.java b/src/main/java/study/Application.java new file mode 100644 index 0000000..6aa7d7e --- /dev/null +++ b/src/main/java/study/Application.java @@ -0,0 +1,11 @@ +package study; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/src/main/java/study/Functions.java b/src/main/java/study/Functions.java new file mode 100644 index 0000000..2be14ea --- /dev/null +++ b/src/main/java/study/Functions.java @@ -0,0 +1,15 @@ +package study; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Component; // Configuration 대신 Component 사용 + +import java.time.LocalDate; + +@Component // Bean으로 등록 +public class Functions { + @Tool(description = "Calculate a date after adding days from today") + public String addDaysFromToday(int days) { + var result = LocalDate.now().plusDays(days); + return result.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/study/JokeController.java b/src/main/java/study/JokeController.java new file mode 100644 index 0000000..82ee5cd --- /dev/null +++ b/src/main/java/study/JokeController.java @@ -0,0 +1,73 @@ +package study; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.prompt.SystemPromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +@RestController +public class JokeController { + private final ChatClient chatClient; + private final Functions functions; // Functions 필드 추가 + + public JokeController(ChatClient.Builder builder, Functions functions) { + this.chatClient = builder.build(); + this.functions = functions; // 주입된 functions 사용 + } + + @GetMapping("/joke") + public ChatResponse joke( + @RequestParam(defaultValue = "ijuhae") String name, + @RequestParam(defaultValue = "pirate") String voice) { + var userMessage = new UserMessage(""" + Tell me about three famous pirates from the Golden Age of Piracy and what they did. + Write at least one sentence for each pirate. + """); + var systemPromptTemplate = new SystemPromptTemplate(""" + You are a helpful AI assistant. + You are an AI assistant that helps people find information. + Your name is {name}. + You should reply to the user's request using your name and in the style of a {voice}. + """ + ); + var systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice)); + var prompt = new Prompt(List.of(userMessage, systemMessage)); + return chatClient.prompt(prompt).call().chatResponse(); + } + + @GetMapping("/actors") + public ActorFilms actors(@RequestParam(defaultValue = "Tom Cruise") String actor) { + var converter = new BeanOutputConverter<>(ActorFilms.class); + var format = converter.getFormat(); + var userMessage = """ + Generate the filmography of 5 movies for {actor}. + {format} + """; + var promptTemplate = new PromptTemplate(userMessage); + var prompt = promptTemplate.create(Map.of("actor", actor, "format", format)); + var text = chatClient.prompt(prompt).call().content(); + return converter.convert(text); + } + + + @GetMapping("/addDays") + public String addDays(@RequestParam(defaultValue = "0") int days) { + var template = new PromptTemplate("Tell me the date after {days} days from today"); + var prompt = template.render(Map.of("days", days)); + return chatClient.prompt() + .user(prompt) + .tools(this.functions) + .call() + .content(); + } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/controller/WeatherController.java b/src/main/java/sunshine/weather/controller/WeatherController.java new file mode 100644 index 0000000..a5042b5 --- /dev/null +++ b/src/main/java/sunshine/weather/controller/WeatherController.java @@ -0,0 +1,22 @@ +package sunshine.weather.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import sunshine.weather.service.WeatherService; + +@RestController +@RequestMapping("/api/weather") +public class WeatherController { + private final WeatherService weatherService; + + public WeatherController(WeatherService weatherService) { + this.weatherService = weatherService; + } + + @GetMapping("/{city}") + public String getWeatherSummary(@PathVariable String city) { + return weatherService.getWeatherSummary(city); + } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/dto/ForecastResponse.java b/src/main/java/sunshine/weather/dto/ForecastResponse.java new file mode 100644 index 0000000..420f7a7 --- /dev/null +++ b/src/main/java/sunshine/weather/dto/ForecastResponse.java @@ -0,0 +1,11 @@ +package sunshine.weather.dto; + +public record ForecastResponse(Current current) { + public record Current( + double temperature_2m, + double apparent_temperature, + int weather_code, + int relative_humidity_2m, + double wind_speed_10m + ) {} +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/model/City.java b/src/main/java/sunshine/weather/model/City.java new file mode 100644 index 0000000..2c4d9cf --- /dev/null +++ b/src/main/java/sunshine/weather/model/City.java @@ -0,0 +1,25 @@ +package sunshine.weather.model; + +public class City { + private final String name; + private final double latitude; + private final double longitude; + + public City(String name, double latitude, double longitude) { + this.name = name; + this.latitude = latitude; + this.longitude = longitude; + } + + public String getName() { + return name; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/model/WeatherCode.java b/src/main/java/sunshine/weather/model/WeatherCode.java new file mode 100644 index 0000000..2843cfc --- /dev/null +++ b/src/main/java/sunshine/weather/model/WeatherCode.java @@ -0,0 +1,40 @@ +package sunshine.weather.model; + +public enum WeatherCode { + CLEAR_SKY(new int[]{0}, "맑음"), + PARTLY_CLOUDY(new int[]{1, 2, 3}, "구름 조금"), + CLOUDY(new int[]{45, 48}, "흐림"), + RAIN(new int[]{51, 53, 55, 56, 57, 61, 63, 65, 66, 67}, "비"), + SNOW(new int[]{71, 73, 75, 77}, "눈"), + THUNDERSTORM(new int[]{95, 96, 99}, "천둥번개"); + + private final int[] codes; + private final String description; + + WeatherCode(int[] codes, String description) { + this.codes = codes; + this.description = description; + } + + public static String getDescription(int code) { + return findWeatherByCode(code).description; + } + + private static WeatherCode findWeatherByCode(int code) { + for (WeatherCode weather : values()) { + if (containsCode(weather.codes, code)) { + return weather; + } + } + return CLEAR_SKY; + } + + private static boolean containsCode(int[] codes, int targetCode) { + for (int code : codes) { + if (code == targetCode) { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/service/OpenMeteo.java b/src/main/java/sunshine/weather/service/OpenMeteo.java new file mode 100644 index 0000000..a52c4f8 --- /dev/null +++ b/src/main/java/sunshine/weather/service/OpenMeteo.java @@ -0,0 +1,40 @@ +package sunshine.weather.service; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; +import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.City; + +@Component +public class OpenMeteo { + private final RestClient client; + + public OpenMeteo(RestClient.Builder builder) { + this.client = builder.build(); + } + + public ForecastResponse.Current fetchCurrent(City city) { + var uri = UriComponentsBuilder.fromUriString("https://api.open-meteo.com/v1/forecast") + .queryParam("latitude", city.getLatitude()) + .queryParam("longitude", city.getLongitude()) + .queryParam("current", "temperature_2m", "weather_code", "relative_humidity_2m", "wind_speed_10m", "apparent_temperature") + .toUriString(); + + try { + var response = client.get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(ForecastResponse.class); + + if (response == null || response.current() == null) { + throw new IllegalStateException("response is null"); + } + return response.current(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/service/WeatherService.java b/src/main/java/sunshine/weather/service/WeatherService.java new file mode 100644 index 0000000..6a7a07a --- /dev/null +++ b/src/main/java/sunshine/weather/service/WeatherService.java @@ -0,0 +1,54 @@ +package sunshine.weather.service; + +import org.springframework.stereotype.Service; +import sunshine.weather.model.City; +import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.WeatherCode; +import java.util.Map; + +@Service +public class WeatherService { + private final OpenMeteo openMeteo; + private final Map cities; + + public WeatherService(OpenMeteo openMeteo) { + this.openMeteo = openMeteo; + this.cities = initializeCities(); + } + + private Map initializeCities() { + return Map.of( + "seoul", new City("Seoul", 37.5665, 126.9780), + "tokyo", new City("Tokyo", 35.6762, 139.6503), + "newyork", new City("New York", 40.7128, -74.0060), + "paris", new City("Paris", 48.8566, 2.3522), + "london", new City("London", 51.5074, -0.1278) + ); + } + + public String getWeatherSummary(String cityName) { + City city = findCity(cityName); + ForecastResponse.Current weather = openMeteo.fetchCurrent(city); + return generateSummary(city, weather); + } + + private City findCity(String cityName) { + String normalizedCityName = cityName.toLowerCase(); + if (!cities.containsKey(normalizedCityName)) { + throw new IllegalArgumentException("지원하지 않는 도시입니다: " + cityName); + } + return cities.get(normalizedCityName); + } + + private String generateSummary(City city, ForecastResponse.Current weather) { + return String.format( + "현재 %s의 기온은 %.1f°C이며, 체감온도는 %.1f°C입니다. 습도는 %d%%이고, 풍속은 %.1fm/s입니다. 날씨는 %s입니다.", + city.getName(), + weather.temperature_2m(), + weather.apparent_temperature(), + weather.relative_humidity_2m(), + weather.wind_speed_10m(), + WeatherCode.getDescription(weather.weather_code()) + ); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 10fffc9..ffe5653 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,3 @@ spring.application.name=spring-sunshine +spring.ai.google.genai.api-key=AIzaSyDoWWOxZq4HV-wJwSP2u-Ws6gDZXOpg6Tg +spring.ai.google.genai.chat.options.model=gemini-2.5-flash-lite diff --git a/src/test/java/sunshine/service/WeatherServiceTest.java b/src/test/java/sunshine/service/WeatherServiceTest.java new file mode 100644 index 0000000..253fd3f --- /dev/null +++ b/src/test/java/sunshine/service/WeatherServiceTest.java @@ -0,0 +1,56 @@ +package sunshine.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.City; +import sunshine.weather.service.OpenMeteo; +import sunshine.weather.service.WeatherService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class WeatherServiceTest { + + @Mock + private OpenMeteo openMeteo; + private WeatherService weatherService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + weatherService = new WeatherService(openMeteo); + } + + @Test + @DisplayName("존재하는 도시의 날씨 정보를 조회할 수 있다") + void getWeatherSummaryForValidCity() { + // given + ForecastResponse.Current mockWeather = new ForecastResponse.Current( + 20.5, 19.0, 0, 65, 5.7 + ); + when(openMeteo.fetchCurrent(any(City.class))).thenReturn(mockWeather); + + // when + String result = weatherService.getWeatherSummary("seoul"); + + // then + assertThat(result).contains("Seoul"); + assertThat(result).contains("20.5°C"); + assertThat(result).contains("맑음"); + } + + @Test + @DisplayName("지원하지 않는 도시명으로 조회시 예외가 발생한다") + void throwExceptionForInvalidCity() { + // when & then + assertThatThrownBy(() -> weatherService.getWeatherSummary("invalid")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("지원하지 않는 도시입니다"); + } +} \ No newline at end of file From b50dc7cc87ab17b7e06b6b7350ca4d53c385840d Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 10:23:43 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EA=B6=8C=EC=97=AD=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=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 --- .../weather/service/CityResolver.java | 7 ++ .../weather/service/LlmCityResolver.java | 100 ++++++++++++++++++ .../weather/service/WeatherService.java | 26 +---- .../sunshine/service/WeatherServiceTest.java | 24 +++-- 4 files changed, 128 insertions(+), 29 deletions(-) create mode 100644 src/main/java/sunshine/weather/service/CityResolver.java create mode 100644 src/main/java/sunshine/weather/service/LlmCityResolver.java diff --git a/src/main/java/sunshine/weather/service/CityResolver.java b/src/main/java/sunshine/weather/service/CityResolver.java new file mode 100644 index 0000000..de34639 --- /dev/null +++ b/src/main/java/sunshine/weather/service/CityResolver.java @@ -0,0 +1,7 @@ +package sunshine.weather.service; + +import sunshine.weather.model.City; + +public interface CityResolver { + City resolve(String inputCityName); +} diff --git a/src/main/java/sunshine/weather/service/LlmCityResolver.java b/src/main/java/sunshine/weather/service/LlmCityResolver.java new file mode 100644 index 0000000..0ab3cea --- /dev/null +++ b/src/main/java/sunshine/weather/service/LlmCityResolver.java @@ -0,0 +1,100 @@ +package sunshine.weather.service; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Component; +import sunshine.weather.model.City; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +// ... existing code ... + +@Component +public class LlmCityResolver implements CityResolver { + + private final ChatClient chatClient; + + /** + * 간단 in-memory 캐시 (원하면 Spring Cache로 교체 가능) + */ + private final Map cache = new ConcurrentHashMap<>(); + + public LlmCityResolver(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + @Override + public City resolve(String inputCityName) { + if (inputCityName == null || inputCityName.isBlank()) { + throw new IllegalArgumentException("도시 이름은 비어 있을 수 없습니다."); + } + + String key = normalize(inputCityName); + City cached = cache.get(key); + if (cached != null) return cached; + + var converter = new BeanOutputConverter<>(CityGeo.class); + var format = converter.getFormat(); + + var userMessage = """ + 너는 지오코딩 도우미야. + 사용자가 입력한 도시/지역 이름을 보고, 해당 위치를 대표하는 좌표(위도/경도)를 반환해. + + 반드시 아래 형식 지시(format)를 따르고, 다른 텍스트는 절대 포함하지 마. + 모르면 임의로 만들지 말고 latitude/longitude를 null로 반환해. + + 입력: "{city}" + + {format} + """; + + var promptTemplate = new PromptTemplate(userMessage); + var prompt = promptTemplate.create(Map.of( + "city", inputCityName, + "format", format + )); + + // ✅ /actors 예제와 동일한 패턴: call().content() + String text = chatClient.prompt(prompt).call().content(); + + CityGeo geo = converter.convert(text); + City city = validateAndToCity(geo, inputCityName); + + cache.put(key, city); + return city; + } + + private String normalize(String s) { + return s.trim().toLowerCase(Locale.ROOT); + } + + private City validateAndToCity(CityGeo geo, String originalInput) { + if (geo == null) { + throw new IllegalArgumentException("도시 좌표를 확인할 수 없습니다: " + originalInput); + } + if (geo.latitude() == null || geo.longitude() == null) { + throw new IllegalArgumentException("도시 좌표를 확인할 수 없습니다: " + originalInput); + } + + double lat = geo.latitude(); + double lon = geo.longitude(); + + if (Double.isNaN(lat) || Double.isNaN(lon) || Double.isInfinite(lat) || Double.isInfinite(lon)) { + throw new IllegalArgumentException("도시 좌표 형식이 올바르지 않습니다: " + originalInput); + } + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { + throw new IllegalArgumentException("도시 좌표 범위를 벗어났습니다: " + originalInput); + } + + String name = (geo.name() == null || geo.name().isBlank()) ? originalInput.trim() : geo.name().trim(); + return new City(name, lat, lon); + } + + /** + * LLM 구조화 출력용 DTO (BeanOutputConverter 대상) + */ + public record CityGeo(String name, Double latitude, Double longitude) {} +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/service/WeatherService.java b/src/main/java/sunshine/weather/service/WeatherService.java index 6a7a07a..7ac74f2 100644 --- a/src/main/java/sunshine/weather/service/WeatherService.java +++ b/src/main/java/sunshine/weather/service/WeatherService.java @@ -9,37 +9,19 @@ @Service public class WeatherService { private final OpenMeteo openMeteo; - private final Map cities; + private final CityResolver cityResolver; - public WeatherService(OpenMeteo openMeteo) { + public WeatherService(OpenMeteo openMeteo, CityResolver cityResolver) { this.openMeteo = openMeteo; - this.cities = initializeCities(); - } - - private Map initializeCities() { - return Map.of( - "seoul", new City("Seoul", 37.5665, 126.9780), - "tokyo", new City("Tokyo", 35.6762, 139.6503), - "newyork", new City("New York", 40.7128, -74.0060), - "paris", new City("Paris", 48.8566, 2.3522), - "london", new City("London", 51.5074, -0.1278) - ); + this.cityResolver = cityResolver; } public String getWeatherSummary(String cityName) { - City city = findCity(cityName); + City city = cityResolver.resolve(cityName); ForecastResponse.Current weather = openMeteo.fetchCurrent(city); return generateSummary(city, weather); } - private City findCity(String cityName) { - String normalizedCityName = cityName.toLowerCase(); - if (!cities.containsKey(normalizedCityName)) { - throw new IllegalArgumentException("지원하지 않는 도시입니다: " + cityName); - } - return cities.get(normalizedCityName); - } - private String generateSummary(City city, ForecastResponse.Current weather) { return String.format( "현재 %s의 기온은 %.1f°C이며, 체감온도는 %.1f°C입니다. 습도는 %d%%이고, 풍속은 %.1fm/s입니다. 날씨는 %s입니다.", diff --git a/src/test/java/sunshine/service/WeatherServiceTest.java b/src/test/java/sunshine/service/WeatherServiceTest.java index 253fd3f..b230c1e 100644 --- a/src/test/java/sunshine/service/WeatherServiceTest.java +++ b/src/test/java/sunshine/service/WeatherServiceTest.java @@ -7,6 +7,7 @@ import org.mockito.MockitoAnnotations; import sunshine.weather.dto.ForecastResponse; import sunshine.weather.model.City; +import sunshine.weather.service.CityResolver; import sunshine.weather.service.OpenMeteo; import sunshine.weather.service.WeatherService; @@ -20,19 +21,24 @@ class WeatherServiceTest { @Mock private OpenMeteo openMeteo; private WeatherService weatherService; + private CityResolver cityResolver; + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - weatherService = new WeatherService(openMeteo); + weatherService = new WeatherService(openMeteo, cityResolver); } @Test - @DisplayName("존재하는 도시의 날씨 정보를 조회할 수 있다") + @DisplayName("도시 입력에 대해 LLM(리졸버)이 반환한 좌표로 날씨 정보를 조회할 수 있다") void getWeatherSummaryForValidCity() { // given + when(cityResolver.resolve("seoul")) + .thenReturn(new City("Seoul", 37.5665, 126.9780)); + ForecastResponse.Current mockWeather = new ForecastResponse.Current( - 20.5, 19.0, 0, 65, 5.7 + 20.5, 19.0, 0, 65, 5.7 ); when(openMeteo.fetchCurrent(any(City.class))).thenReturn(mockWeather); @@ -46,11 +52,15 @@ void getWeatherSummaryForValidCity() { } @Test - @DisplayName("지원하지 않는 도시명으로 조회시 예외가 발생한다") - void throwExceptionForInvalidCity() { + @DisplayName("LLM(리졸버)이 좌표를 찾지 못하면 예외가 발생한다") + void throwExceptionWhenResolverFails() { + // given + when(cityResolver.resolve("invalid")) + .thenThrow(new IllegalArgumentException("도시 좌표를 확인할 수 없습니다: invalid")); + // when & then assertThatThrownBy(() -> weatherService.getWeatherSummary("invalid")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("지원하지 않는 도시입니다"); + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("도시 좌표를 확인할 수 없습니다"); } } \ No newline at end of file From 83e0674256d3b60347d9ec98802712045847a549 Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 10:40:57 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=98=B7=EC=B0=A8=EB=A6=BC=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/service/LlmOutfitRecommender.java | 59 +++++++++++++ .../weather/service/LlmWeatherAdvisor.java | 82 +++++++++++++++++++ .../weather/service/WeatherService.java | 23 +++--- 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/main/java/sunshine/weather/service/LlmOutfitRecommender.java create mode 100644 src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java diff --git a/src/main/java/sunshine/weather/service/LlmOutfitRecommender.java b/src/main/java/sunshine/weather/service/LlmOutfitRecommender.java new file mode 100644 index 0000000..9c22179 --- /dev/null +++ b/src/main/java/sunshine/weather/service/LlmOutfitRecommender.java @@ -0,0 +1,59 @@ +package sunshine.weather.service; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.stereotype.Component; +import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.City; +import sunshine.weather.model.WeatherCode; + +import java.util.Map; + +@Component +public class LlmOutfitRecommender { + + private final ChatClient chatClient; + + public LlmOutfitRecommender(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + public String recommend(City city, ForecastResponse.Current w) { + String weatherDesc = WeatherCode.getDescription(w.weather_code()); + + String userMessage = """ + 너는 날씨 기반 복장 추천 스타일리스트야. + 아래 입력(도시, 현재 날씨 수치)을 기반으로 오늘 입기 좋은 복장을 한국어로 추천해줘. + + 요구사항: + - 2~4문장으로 작성 + - 기온/체감온도/바람/습도/강수 가능성을 고려해서 이유를 짧게 포함 + - 과장하지 말고, 애매하면 "가벼운 겉옷" 같이 안전한 표현 사용 + - 특정 브랜드 언급 금지 + - 우산/방수 같은 준비물도 필요하면 포함 + + [도시] + - {cityName} + + [현재 날씨] + - 기온: {t}°C + - 체감: {a}°C + - 습도: {h}% + - 풍속: {w}m/s + - 상태: {desc} (code={code}) + """; + + var promptTemplate = new PromptTemplate(userMessage); + var prompt = promptTemplate.create(Map.of( + "cityName", city.getName(), + "t", String.format("%.1f", w.temperature_2m()), + "a", String.format("%.1f", w.apparent_temperature()), + "h", String.valueOf(w.relative_humidity_2m()), + "w", String.format("%.1f", w.wind_speed_10m()), + "desc", weatherDesc, + "code", String.valueOf(w.weather_code()) + )); + + return chatClient.prompt(prompt).call().content(); + } +} diff --git a/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java b/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java new file mode 100644 index 0000000..41a9e0c --- /dev/null +++ b/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java @@ -0,0 +1,82 @@ +package sunshine.weather.service; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.stereotype.Component; +import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.City; +import sunshine.weather.model.WeatherCode; + +import java.util.Map; + +@Component +public class LlmWeatherAdvisor { + + private final ChatClient chatClient; + + public LlmWeatherAdvisor(ChatClient.Builder chatClientBuilder) { + this.chatClient = chatClientBuilder.build(); + } + + public Advice advise(City city, ForecastResponse.Current w) { + var converter = new BeanOutputConverter<>(Advice.class); + var format = converter.getFormat(); + + String weatherDesc = WeatherCode.getDescription(w.weather_code()); + + var userMessage = """ + 너는 한국어로 답하는 날씨 리포터이자 스타일리스트야. + 입력된 "현재 날씨 수치"를 바탕으로 + 1) 날씨 요약(weatherSummary) + 2) 옷차림 추천(outfitSummary) + 을 생성해. + + 제약: + - 반드시 {format} 형식만 출력 (다른 텍스트 금지) + - weatherSummary는 2~3문장, 수치(기온/체감/습도/풍속)와 상태를 자연스럽게 포함 + - outfitSummary는 2~4문장, 기온/체감/바람/강수 가능성을 근거로 추천 + - 과장, 단정적 예보 금지(“가능성”, “권장” 등 안전한 표현) + - 브랜드 언급 금지 + + [도시] + - 이름: {cityName} + + [현재 날씨] + - 기온: {t}°C + - 체감: {a}°C + - 습도: {h}% + - 풍속: {ws}m/s + - 상태: {desc} (code={code}) + + {format} + """; + + var promptTemplate = new PromptTemplate(userMessage); + var prompt = promptTemplate.create(Map.of( + "format", format, + "cityName", city.getName(), + "t", String.format("%.1f", w.temperature_2m()), + "a", String.format("%.1f", w.apparent_temperature()), + "h", String.valueOf(w.relative_humidity_2m()), + "ws", String.format("%.1f", w.wind_speed_10m()), + "desc", weatherDesc, + "code", String.valueOf(w.weather_code()) + )); + + String text = chatClient.prompt(prompt).call().content(); + Advice advice = converter.convert(text); + + if (advice == null || isBlank(advice.weatherSummary()) || isBlank(advice.outfitSummary())) { + throw new IllegalStateException("LLM 응답을 파싱했지만 필수 필드가 비어 있습니다."); + } + + return advice; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + public record Advice(String weatherSummary, String outfitSummary) { } +} \ No newline at end of file diff --git a/src/main/java/sunshine/weather/service/WeatherService.java b/src/main/java/sunshine/weather/service/WeatherService.java index 7ac74f2..6375759 100644 --- a/src/main/java/sunshine/weather/service/WeatherService.java +++ b/src/main/java/sunshine/weather/service/WeatherService.java @@ -3,19 +3,22 @@ import org.springframework.stereotype.Service; import sunshine.weather.model.City; import sunshine.weather.dto.ForecastResponse; -import sunshine.weather.model.WeatherCode; -import java.util.Map; @Service public class WeatherService { private final OpenMeteo openMeteo; private final CityResolver cityResolver; + private final LlmWeatherAdvisor weatherAdvisor; - public WeatherService(OpenMeteo openMeteo, CityResolver cityResolver) { + + + public WeatherService(OpenMeteo openMeteo, CityResolver cityResolver, LlmWeatherAdvisor weatherAdvisor) { this.openMeteo = openMeteo; this.cityResolver = cityResolver; + this.weatherAdvisor = weatherAdvisor; } + public String getWeatherSummary(String cityName) { City city = cityResolver.resolve(cityName); ForecastResponse.Current weather = openMeteo.fetchCurrent(city); @@ -23,14 +26,10 @@ public String getWeatherSummary(String cityName) { } private String generateSummary(City city, ForecastResponse.Current weather) { - return String.format( - "현재 %s의 기온은 %.1f°C이며, 체감온도는 %.1f°C입니다. 습도는 %d%%이고, 풍속은 %.1fm/s입니다. 날씨는 %s입니다.", - city.getName(), - weather.temperature_2m(), - weather.apparent_temperature(), - weather.relative_humidity_2m(), - weather.wind_speed_10m(), - WeatherCode.getDescription(weather.weather_code()) - ); + LlmWeatherAdvisor.Advice advice = weatherAdvisor.advise(city, weather); + + // API 응답이 String이므로, 한 덩어리로 합쳐서 반환 + return advice.weatherSummary() + System.lineSeparator() + + advice.outfitSummary(); } } \ No newline at end of file From f659ab6e90ee2ad932c23815f17225ea36dffc7a Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 14:47:24 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EA=B0=81=20=EC=9A=94=EC=B2=AD=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=B4=20=EC=82=AC=EC=9A=A9=EB=9F=89=EA=B3=BC=20?= =?UTF-8?q?=EB=B9=84=EC=9A=A9=20=EC=B6=94=EC=A0=95=EC=B9=98=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/sunshine/Application.java | 2 + .../weather/config/LlmCostProperties.java | 11 +++ .../weather/service/LlmCostEstimator.java | 33 +++++++ .../weather/service/LlmWeatherAdvisor.java | 88 +++++++++++++++++-- src/main/resources/application.properties | 3 - src/main/resources/application.yml | 0 6 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/main/java/sunshine/weather/config/LlmCostProperties.java create mode 100644 src/main/java/sunshine/weather/service/LlmCostEstimator.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml diff --git a/src/main/java/sunshine/Application.java b/src/main/java/sunshine/Application.java index b5d46a4..ff285ad 100644 --- a/src/main/java/sunshine/Application.java +++ b/src/main/java/sunshine/Application.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/main/java/sunshine/weather/config/LlmCostProperties.java b/src/main/java/sunshine/weather/config/LlmCostProperties.java new file mode 100644 index 0000000..f2afccd --- /dev/null +++ b/src/main/java/sunshine/weather/config/LlmCostProperties.java @@ -0,0 +1,11 @@ +package sunshine.weather.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.math.BigDecimal; + +@ConfigurationProperties(prefix = "app.llm-cost") +public record LlmCostProperties( + BigDecimal inputPer1k, + BigDecimal outputPer1k +) {} diff --git a/src/main/java/sunshine/weather/service/LlmCostEstimator.java b/src/main/java/sunshine/weather/service/LlmCostEstimator.java new file mode 100644 index 0000000..24706ba --- /dev/null +++ b/src/main/java/sunshine/weather/service/LlmCostEstimator.java @@ -0,0 +1,33 @@ +package sunshine.weather.service; + +import org.springframework.stereotype.Component; +import sunshine.weather.config.LlmCostProperties; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Component +public class LlmCostEstimator { + + private final LlmCostProperties props; + + public LlmCostEstimator(LlmCostProperties props) { + this.props = props; + } + + public BigDecimal estimateUsd(long inputTokens, long outputTokens) { + if (props.inputPer1k() == null || props.outputPer1k() == null) { + return BigDecimal.ZERO; + } + + BigDecimal in = props.inputPer1k() + .multiply(BigDecimal.valueOf(inputTokens)) + .divide(BigDecimal.valueOf(1000), 10, RoundingMode.HALF_UP); + + BigDecimal out = props.outputPer1k() + .multiply(BigDecimal.valueOf(outputTokens)) + .divide(BigDecimal.valueOf(1000), 10, RoundingMode.HALF_UP); + + return in.add(out).setScale(6, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java b/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java index 41a9e0c..bd39065 100644 --- a/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java +++ b/src/main/java/sunshine/weather/service/LlmWeatherAdvisor.java @@ -1,6 +1,12 @@ package sunshine.weather.service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.stereotype.Component; @@ -8,15 +14,21 @@ import sunshine.weather.model.City; import sunshine.weather.model.WeatherCode; +import java.math.BigDecimal; import java.util.Map; +import java.util.Objects; @Component public class LlmWeatherAdvisor { + private static final Logger log = LoggerFactory.getLogger(LlmWeatherAdvisor.class); + private final ChatClient chatClient; + private final LlmCostEstimator llmCostEstimator; - public LlmWeatherAdvisor(ChatClient.Builder chatClientBuilder) { + public LlmWeatherAdvisor(ChatClient.Builder chatClientBuilder, LlmCostEstimator llmCostEstimator) { this.chatClient = chatClientBuilder.build(); + this.llmCostEstimator = llmCostEstimator; } public Advice advise(City city, ForecastResponse.Current w) { @@ -63,19 +75,81 @@ public Advice advise(City city, ForecastResponse.Current w) { "desc", weatherDesc, "code", String.valueOf(w.weather_code()) )); + ChatResponse response = chatClient.prompt(prompt).call().chatResponse(); - String text = chatClient.prompt(prompt).call().content(); + String text = extractText(response); Advice advice = converter.convert(text); - if (advice == null || isBlank(advice.weatherSummary()) || isBlank(advice.outfitSummary())) { - throw new IllegalStateException("LLM 응답을 파싱했지만 필수 필드가 비어 있습니다."); - } + + // 요청별 사용량/비용 로깅 + LlmUsage usage = extractUsage(response); + BigDecimal estimatedUsd = llmCostEstimator.estimateUsd(usage.inputTokens, usage.outputTokens); + + log.info( + "llm_usage feature=weather_advice model={} requestId={} " + + "inputTokens={} outputTokens={} totalTokens={} estimatedUsd={} city={}", + usage.model(), + usage.requestId(), + usage.inputTokens(), + usage.outputTokens(), + usage.totalTokens(), + estimatedUsd.toPlainString(), + city.getName() + ); return advice; } - private boolean isBlank(String s) { - return s == null || s.isBlank(); + private String extractText(ChatResponse response) { + if (response == null || response.getResults() == null || response.getResults().isEmpty()) { + throw new IllegalStateException("LLM 응답이 비어 있습니다."); + } + + var first = response.getResults().getFirst(); + AssistantMessage msg = first.getOutput(); + return Objects.requireNonNullElse(msg.getText(), "").trim(); + } + + + private LlmUsage extractUsage(ChatResponse response) { + try { + if (response == null) { + return LlmUsage.unknown(); + } + + ChatResponseMetadata md = response.getMetadata(); + if (md == null) { + return LlmUsage.unknown(); + } + + String model = md.getModel(); + String requestId = md.getId(); + + Usage usageObj = md.getUsage(); + long input = 0, output = 0, total = 0; + + if (usageObj != null) { + input = usageObj.getPromptTokens(); + output = usageObj.getCompletionTokens(); + total = usageObj.getTotalTokens(); + } + + return new LlmUsage(model, requestId, input, output, total); + } catch (Exception e) { + return LlmUsage.unknown(); + } + } + + private record LlmUsage( + String model, + String requestId, + long inputTokens, + long outputTokens, + long totalTokens + ) { + static LlmUsage unknown() { + return new LlmUsage( "unknown-model", "unknown-request", 0, 0, 0); + } } public record Advice(String weatherSummary, String outfitSummary) { } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index ffe5653..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,3 +0,0 @@ -spring.application.name=spring-sunshine -spring.ai.google.genai.api-key=AIzaSyDoWWOxZq4HV-wJwSP2u-Ws6gDZXOpg6Tg -spring.ai.google.genai.chat.options.model=gemini-2.5-flash-lite diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e69de29 From 0a6b7d3a5f79cf28fd7c88ca363de5d8caa94918 Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 14:56:44 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=EC=9A=94=EC=95=BD=EC=9D=84=20=EC=83=88=EB=A1=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0,=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=9A=94=EC=95=BD=EC=9D=84=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weather/service/WeatherService.java | 49 +++++++++++++++++-- src/main/resources/application.yml | 18 +++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/main/java/sunshine/weather/service/WeatherService.java b/src/main/java/sunshine/weather/service/WeatherService.java index 6375759..dd34aa5 100644 --- a/src/main/java/sunshine/weather/service/WeatherService.java +++ b/src/main/java/sunshine/weather/service/WeatherService.java @@ -1,24 +1,30 @@ package sunshine.weather.service; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import sunshine.weather.model.City; import sunshine.weather.dto.ForecastResponse; +import sunshine.weather.model.City; +import sunshine.weather.model.WeatherCode; @Service public class WeatherService { private final OpenMeteo openMeteo; private final CityResolver cityResolver; private final LlmWeatherAdvisor weatherAdvisor; + private final boolean llmEnabled; - - - public WeatherService(OpenMeteo openMeteo, CityResolver cityResolver, LlmWeatherAdvisor weatherAdvisor) { + public WeatherService( + OpenMeteo openMeteo, + CityResolver cityResolver, + LlmWeatherAdvisor weatherAdvisor, + @Value("${sunshine.llm.enabled:false}") boolean llmEnabled + ) { this.openMeteo = openMeteo; this.cityResolver = cityResolver; this.weatherAdvisor = weatherAdvisor; + this.llmEnabled = llmEnabled; } - public String getWeatherSummary(String cityName) { City city = cityResolver.resolve(cityName); ForecastResponse.Current weather = openMeteo.fetchCurrent(city); @@ -26,10 +32,43 @@ public String getWeatherSummary(String cityName) { } private String generateSummary(City city, ForecastResponse.Current weather) { + if (!llmEnabled) { + return generateTemplateSummary(city, weather); + } + LlmWeatherAdvisor.Advice advice = weatherAdvisor.advise(city, weather); // API 응답이 String이므로, 한 덩어리로 합쳐서 반환 return advice.weatherSummary() + System.lineSeparator() + advice.outfitSummary(); } + + private String generateTemplateSummary(City city, ForecastResponse.Current w) { + String desc = WeatherCode.getDescription(w.weather_code()); + + String weatherSummary = String.format( + "%s 현재 날씨는 %s입니다. 기온 %.1f°C(체감 %.1f°C), 습도 %d%%, 풍속 %.1fm/s 입니다.", + city.getName(), + desc, + w.temperature_2m(), + w.apparent_temperature(), + w.relative_humidity_2m(), + w.wind_speed_10m() + ); + + // 아주 단순한 규칙 기반 옷차림 템플릿(필요 시 규칙 더 추가 가능) + String outfitSummary; + double t = w.apparent_temperature(); + if (t <= 5) { + outfitSummary = "두꺼운 외투(패딩/코트)와 목도리 등 보온을 권장해요. 바람이 있으면 체감이 더 낮을 수 있어요."; + } else if (t <= 15) { + outfitSummary = "가벼운 자켓/가디건 레이어드를 권장해요. 바람이 있으면 얇은 바람막이가 도움이 될 수 있어요."; + } else if (t <= 23) { + outfitSummary = "긴팔 또는 얇은 겉옷 정도가 무난해요. 실내외 온도 차에 대비해 가벼운 겉옷을 챙기면 좋아요."; + } else { + outfitSummary = "가볍고 통풍이 좋은 옷차림을 권장해요. 수분 보충과 자외선 대비도 함께 챙겨요."; + } + + return weatherSummary + System.lineSeparator() + outfitSummary; + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..5dbabff 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,18 @@ +app: + llm-cost: + input-per-1k: 0.00015 + output-per-1k: 0.00060 + +sunshine: + llm: + enabled: true + +spring: + application: + name: spring-sunshine + ai: + google: + genai: + api-key: {YOUR_API_KEY} + chat.options.model: gemini-2.5-flash-lite + From ed1c7d413475f27ff64005255251c6b9f82e266b Mon Sep 17 00:00:00 2001 From: ijuhae Date: Tue, 16 Dec 2025 15:05:01 +0900 Subject: [PATCH 6/6] =?UTF-8?q?README.md=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 41da1ac..ec26586 100644 --- a/README.md +++ b/README.md @@ -1 +1,94 @@ -# spring-sunshine-precourse \ No newline at end of file +### 1) API 개요 + +- **Endpoint**: `GET /api/weather/{city}` +- **입력**: `city` (Path Variable) — 예: `seoul`, `busan`, `tokyo` +- **출력**: `String` (텍스트) + - 기본 모드: 템플릿 기반 “날씨 요약 + 옷차림 추천” + - LLM 모드: LLM이 생성한 “날씨 요약 + 옷차림 추천” (구조화 출력 → 합쳐서 반환) + + +### 2) 구현 방식(흐름) + +요청이 들어오면 아래 순서로 처리됩니다. + +1. **Controller** + - `WeatherController`가 `/api/weather/{city}` 요청을 받고 `WeatherService`에 위임합니다. + +2. **도시 → 좌표 변환(Geocoding)** + - `CityResolver`가 사용자가 입력한 도시명을 `City(name, latitude, longitude)`로 변환합니다. + - 구현체 `LlmCityResolver`는 LLM을 이용해 좌표를 추론하고, 간단한 in-memory 캐시로 반복 요청을 줄입니다. + +3. **Open-Meteo로 현재 날씨 조회** + - `OpenMeteo`가 `https://api.open-meteo.com/v1/forecast`를 호출해 현재 날씨를 받아옵니다. + - 현재 사용 필드: + - `temperature_2m`, `apparent_temperature`, `weather_code`, `relative_humidity_2m`, `wind_speed_10m` + +4. **응답 생성(LLM ON/OFF)** + - `sunshine.llm.enabled` 설정에 따라 분기합니다. + - `false`: 규칙/템플릿 기반 문장 생성 + - `true`: `LlmWeatherAdvisor`가 LLM으로 요약/옷차림을 생성 + (단, **구조화 출력(BeanOutputConverter)** 으로 파싱 가능하게 만들고, 두 문장을 합쳐 반환) + +--- + +### 3) 코드 개선 + +#### (1) Controller는 얇게, Service로 책임 이동 +- Controller에서는 입력만 받고 비즈니스 로직(좌표 변환, 외부 API 호출, 문장 생성)은 모두 Service 레이어로 모았습니다. +- 결과적으로 테스트/확장이 쉬워지고, 웹 레이어가 단순해졌습니다. + +#### (2) 외부 API 호출을 전용 컴포넌트로 분리 (`OpenMeteo`) +- `RestClient` + `UriComponentsBuilder`로 URL/쿼리를 안전하게 구성했습니다. +- 응답이 `null`이거나 `current`가 비어있는 경우 예외를 명확히 던지도록 처리했습니다. + +#### (3) LLM 기능을 “옵션”으로 설계 (Feature Toggle) +- `sunshine.llm.enabled` 값으로 LLM 사용 여부를 쉽게 켜고 끌 수 있게 했습니다. +- LLM을 끄면 **완전한 규칙 기반**으로도 동작하도록 만들어 “LLM 장애/비용”에 대한 리스크를 낮췄습니다. + +#### (4) LLM 출력은 구조화(파싱 가능한 형태)로 강제 +- `LlmWeatherAdvisor` / `LlmCityResolver` 모두 **BeanOutputConverter 기반 포맷**을 사용해 + - “문장만 잔뜩 출력하는” 형태를 피하고 + - DTO로 안정적으로 변환되도록 했습니다. + +#### (5) LLM 비용 추정/관측 가능성(Observability) 추가 +- `LlmCostProperties` + `LlmCostEstimator`로 토큰 사용량을 비용(USD)로 추정합니다. +- `LlmWeatherAdvisor`에서 요청별로 모델/토큰/추정비용을 로그로 남겨, + - “기능은 되는데 비용이 얼마인지 모르는 상태”를 피했습니다. + +--- + +### 4) 학습한 내용 + +- **레이어링 감각** + - Controller는 HTTP 입출력에 집중, Service는 유스케이스/조합 로직, 외부 연동은 전용 컴포넌트로 분리. +- **외부 API 연동 기본기** + - `RestClient` 사용, URI 구성, 응답 null 처리, 예외 래핑 등. +- **LLM을 제품 기능으로 붙일 때의 패턴** + - 토글로 켜고 끄기(안전장치) + - 구조화 출력으로 “파싱 가능한 결과” 만들기 + - 관측(토큰/비용 로그)으로 운영 가능하게 만들기 +- **간단 캐시의 가치** + - 도시 좌표는 자주 반복되므로, 작은 캐시만으로도 LLM 호출을 줄여 응답속도/비용을 동시에 개선 가능. + +--- + +### 5) 설정 값 + +`src/main/resources/application.yml`에서 제어합니다. + +- LLM 사용 여부 + - `sunshine.llm.enabled: true|false` +- LLM 비용 추정 단가 + - `app.llm-cost.input-per-1k` + - `app.llm-cost.output-per-1k` +- (예시) LLM API Key는 실제 값 대신 플레이스홀더로 관리 + - `spring.ai.google.genai.api-key: {YOUR_API_KEY}` + +--- + +### 6) 다음 개선 아이디어 + +- API 응답을 `String` 대신 **JSON DTO**로 변경(클라이언트 사용성 향상) +- 예외를 `@ControllerAdvice`로 모아 HTTP 상태코드/에러 포맷 통일 +- 캐시를 Spring Cache(Caffeine 등)로 교체(만료/크기 제한) +- 도시 좌표는 LLM 대신 실제 지오코딩 API로 대체(정확도 향상)# spring-sunshine-precourse \ No newline at end of file