Skip to content
Open
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
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,74 @@
# spring-sunshine-precourse
# spring-sunshine-precourse

## 과제 진행 요구사항
- 기능 요구사항에 기재되지 않은 내용은 스스로 판단하여 구현
- AI 도구를 사용할 수 있으나, 문제를 이해하지 않고 단순히 복사해서 붙여넣는 것은 허용하지 않음
- Java21로 실행해야함
- gradle-8.4 버전 Java21 미지원으로 gradle 업그레이드
- AngularJS 팀에서 만든 커밋 메시지 컨벤션(https://gist.github.com/stephenparish/9941e89d80e2bc58a153)을 참고해 커밋메시지 작성
- AI 도구를 활용하였다면, README.md에 활용한 방식과 코드를 어떻게 수정하였는지, 무엇을 학습하였는지 기록함

## 기능 요구 사항
- 기능 목록 만들고 기능 단위로 커밋
- 주어진 도시 이름을 입력받아 외부 REST API를 호출해 해당 도시의 날씨 정보를 조회
- 사용자는 도시 이름을 입력하면 해당 도시의 위도와 경도를 기반으로 날씨 정보를 조회할 수 있다.
- 최소 다섯 개 이상의 도시는 반드시 지원해야 한다.
- 도시 이름과 좌표를 매핑하는 자료 구조는 직접 설계한다.
- 서비스는 Open-Meteo API를 호출하여 아래 정보를 조회한다.
- 현재 온도
- 체감 온도
- 하늘 상태(맑음, 흐림 등)
- 습도
- 조회한 데이터를 기반으로 간단한 한 줄 요약 문장을 생성하는 기능을 추가한다

### 1) 기능목록 단위
- 전체 도시 이름 List를 조회해올 수 있다.
- 도시 이름으로 위도/경도 정보를 조회해올 수 있다.
- 위도/경도로 Open-Meteo API를 호출하여, 해당 도시의 날씨 정보를 간단한 한줄 요약 문장을 생성할 수 있다.
- 도시 이름으로 해당 도시의 날씨 정보를 요약해서 반환할 수 있다. (TODO LLM연결..)

### 2) 추가 도출한 요구사항
- 환경별 설정 관리를 위해 application.yml과 application-local.yml을 분리함
- 환경별 DB를 다르게 사용함
- 과제 로컬 환경에서는 H2 DB를 사용함
- 개발/운영 환경에서는 mysql 사용(예정)
- 외부 API 호출시 Feign Client를 사용하였으며, HttpClient로 okhttp를 적용함
- Flyway를 사용하여 환경별로 관리함

## 프로그래밍 요구 사항
- 단순한 규칙이 아니라 문제를 분해하고 명확하게 설계하는 훈련을 위한 장치임을 이해하고 지킴
- 자바 코드 컨벤션을 지키면서 프로그래밍한다.
- 기본적으로 Google Java Style Guide를 원칙으로 한다.
- 단, 들여쓰기는 '2 spaces'가 아닌 '4 spaces'로 한다.
- 들여쓰기 단계가 3을 넘지 않도록 구현한다. 2까지만 허용한다.
- 예를 들어, while문 안의 if문은 들여쓰기 2이다.
- 힌트: 들여쓰기 단계를 줄이는 좋은 방법은 함수를 분리하는 것이다.
- 함수(또는 메서드)의 길이가 15줄을 넘어가지 않도록 구현한다.
- 함수는 한 가지 일만 하도록 작성한다.
- else 키워드를 쓰지 않는다.
- switch문도 허용하지 않는다.
- 힌트: if문에서 값을 반환하는 방식으로 구현하면 else를 사용하지 않아도 된다.
- 3항 연산자를 쓰지 않는다.
- 정리한 기능 목록이 정상적으로 작동하는지 JUnit 5와 AssertJ로 테스트한다.
- 테스트 도구 사용법이 익숙하지 않다면 아래 문서를 참고한다.


## 어플리케이션 아키텍쳐
- DDD의 철학을 일부 반영한 Layered Architecture로 구성
```text
sunshine
├─ domain
│ ├─ infra ← 인프라 인터페이스 (port 역할)
│ └─ service ← 도메인 서비스
├─ infrastructure
│ ├─ entity ← JPA 엔티티
│ ├─ repository ← JPA 구현체
│ └─ http ← Feign
├─ presentation
│ ├─ controller
│ └─ dto
└─ Application
```
46 changes: 46 additions & 0 deletions README_for_LLM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# spring-sunshine-precourse

## 기능 요구 사항
주어진 도시 이름을 입력받아 외부 REST API를 호출해 해당 도시의 날씨 정보를 조회하고, 이를 정리해 반환하는 간단한 웹 서비스를 스프링 프레임워크로 구현한다.

- 조회한 날씨 데이터를 입력으로 LLM API를 호출해 요약을 생성하고 반환한다.
- 설정에 따라 요약을 새로 생성하지 않고, 기존 요약(우리가 만든 LLM)을 재사용할 수 있다.
=> LLM Flag 적용 (비슷한 대답을 유도하는)

- 도시는 물론, 권역 단위로도 날씨를 조회할 수 있다.
- e.g. 서울 전체, 수도권, 특정 구/동 등
=> DB 또는 캐싱에 없으면 권역의 위도/경도를 LLM으로부터 조회

- 날씨 또는 기온을 기준으로 복장을 추천한다.
- e.g. 기온 구간, 강수 여부, 체감온도, 바람 등을 기준으로 추천 규칙을 적용한다.
=> 프롬프트 설정

- 각 요청에 대해 사용량과 비용 추정치를 로그로 남긴다.
- 예: 입력 토큰, 출력 토큰, 총 토큰, 모델명, 캐시 사용 여부, 추정 비용
=> 로깅 방식


```text
[Client]
|
v
[Weather Controller]
|
+--> Location Resolver
| - DB/Cache
| - LLM (fallback: 위도/경도 추론)
|
+--> Weather Provider
| - External Weather API (OpenWeather, KMA 등)
| - Cache
|
+--> Weather Summary Service
| - LLM 요약 (Flag 적용)
| - Summary Cache
|
+--> Outfit Recommendation Engine
| - Rule 기반 + LLM Prompt
|
+--> Usage Logger
- 토큰/비용/캐시 여부
```
28 changes: 26 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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.4.6"
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"
Expand All @@ -19,17 +19,41 @@ repositories {
mavenCentral()
}

dependencyManagement {
imports {
// Spring boot 3.3.x 버전의 Spring Cloud Bom 버전
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.1")
}
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")

/* Open Feign */
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("io.github.openfeign:feign-okhttp")

/* Lombok */
compileOnly("org.projectlombok:lombok:1.18.30")
annotationProcessor("org.projectlombok:lombok")
testCompileOnly("org.projectlombok:lombok:1.18.30")
testAnnotationProcessor("org.projectlombok:lombok")

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")

/* spring ai */
implementation(platform("org.springframework.ai:spring-ai-bom:1.1.2"))
implementation("org.springframework.ai:spring-ai-starter-model-google-genai")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/study/ActorsFilms.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package study;

import java.util.List;

public record ActorsFilms(String actor, List<String> movies) {

}
5 changes: 5 additions & 0 deletions src/main/java/study/AddDayRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package study;

public record AddDayRequest(int days) {

}
13 changes: 13 additions & 0 deletions src/main/java/study/Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package study;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
5 changes: 5 additions & 0 deletions src/main/java/study/DateResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package study;

public record DateResponse(String date) {

}
22 changes: 22 additions & 0 deletions src/main/java/study/FunctionConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package study;

import java.time.LocalDate;
import java.util.function.Function;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;

@Configuration
public class FunctionConfiguration {

@Description("Calculate a date after adding days from today")
@Bean
public Function<AddDayRequest, DateResponse> addDaysFormToday() {
return request -> {
var result = LocalDate.now().plusDays(request.days());
return new DateResponse(result.toString());
};
}

}

106 changes: 106 additions & 0 deletions src/main/java/study/JokeController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package study;

import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
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;

@RestController
@RequiredArgsConstructor
@Log4j2
public class JokeController {
private final ChatClient.Builder chatClientBuilder;

// String 방식
@GetMapping(path = "/v1/joke")
public String joke1(
@RequestParam(defaultValue = "Tell me a joke") String message)
{
return chatClientBuilder
.build()
.prompt(message)
.call()
.content();
}

// chat Response 방식
@GetMapping(path = "/v2/joke")
public ChatResponse joke2(
@RequestParam(defaultValue = "Bob") String name,
@RequestParam(defaultValue = "pirate") String voice)
{
// UserPrompt
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.
"""
);

// SystemPrompt (name, voice)
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(userMessage, systemMessage);

return chatClientBuilder
.build()
.prompt(prompt)
.call()
.chatResponse();
}

// OutputConverter
@GetMapping(path = "/v1/actors")
public ActorsFilms actors(
@RequestParam(defaultValue = "Tom Cruise") String actor) {
var beanOuputConverter = new BeanOutputConverter<>(ActorsFilms.class);
var format = beanOuputConverter.getFormat();
var userMessage = """
Generate the filmography of 5 movies for {actor}.
{format}
""";
log.info(format);

var prompt = new PromptTemplate(userMessage)
.create(Map.of("actor", actor, "format", format));

var text = chatClientBuilder
.build()
.prompt(prompt)
.call()
.content();
log.info(text);

return beanOuputConverter.convert(text);
}

// 함수호출 (Function Calling)
@GetMapping("/v1/addDays")
public String addDays (
@RequestParam(defaultValue = "0") int days
) {
var template = new PromptTemplate("오늘 기준으로 {days}일 뒤 날짜를 알려줘.");
var prompt = template.render(Map.of("days", days));
return chatClientBuilder
.build()
.prompt(prompt)
.toolNames("addDaysFormToday")
.call()
.content();
}

}
8 changes: 5 additions & 3 deletions src/main/java/sunshine/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
15 changes: 15 additions & 0 deletions src/main/java/sunshine/domain/City.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package sunshine.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
@AllArgsConstructor
public class City {

private final String name;
private final double latitude;
private final double longitude;
}
15 changes: 15 additions & 0 deletions src/main/java/sunshine/domain/WeatherSnapShot.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package sunshine.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

@Builder
@Getter
@AllArgsConstructor
public class WeatherSnapShot {
double temperature;
double apparentTemperature;
int humidity;
int weatherCode;
}
Loading