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
159 changes: 158 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,158 @@
# java-baseball-precourse
# 🌤️ 날씨 조회 서비스
도시 이름을 입력받아 Open-Meteo API를 통해 실시간 날씨 정보를 조회하는 Spring 기반 웹 서비스

---
## 📌 구현 목표
사용자가 도시 이름을 입력하면, 해당 도시의 위도·경도를 기반으로 외부 API(Open-Meteo)를 호출하여 다음 정보를 제공하는 간단한 날씨 조회 API를 구현

- 현재 기온
- 체감 온도
- 하늘 상태(맑음, 흐림 등)
- 습도
- 조회된 데이터를 기반으로 한 한 줄 요약 문장
---
## 🏗️ 기술 스택
- Java 21
- Spring Web
- Spring WebFlux (WebClient)
- Lombok
- JUnit5 / AssertJ / MockMvc
- Gradle
---
## 📁 프로젝트 구조
```
src
├── main
│ └── java/com/llm_precourse/weather
│ ├── controller
│ ├── service
│ ├── domain
│ ├── dto
│ ├── exception
│ └── common
└── test
└── java/com/llm_precourse/weather
├── domain
├── service
└── controller
```
---
## 🚀 구현 기능

### 1. 지원 도시 관리 (City enum)
- 최소 5개 도시를 지원하며, 도시명 → 위도/경도 매핑을 Enum 형태로 모델링
- enum 내부에 `Map<String, City>`를 두어 도시 이름(대소문자 무시)으로 O(1)의 복잡도로 도시 정보를 조회할 수 있는 자료구조를 설계
- 도시명 → City 매핑: `BY_NAME` (불변 Map)
- 각 enum 상수는 `cityName`, `cityNameKr`, `latitude`, `longitude`를 보유
- `City.from(String city)` 메서드를 통해 입력값을 정규화 후 enum 조회
- 지원 도시:

| 도시 | 위도 | 경도 |
|---------|---------|----------|
| Seoul | 37.5665 | 126.9780 |
| Tokyo | 35.6762 | 139.6503 |
| NewYork | 40.7128 | -74.0060 |
| Paris | 48.8566 | 2.3522 |
| London | 51.5074 | -0.1278 |

### 2. 날씨 코드 매핑 (WeatherCondition enum)
- Open-Meteo에서 내려주는 `weather_code` 값을 `WeatherCondition` enum으로 모델링
- 각 enum 상수는 `code`(정수 코드)와 `description`(한글 설명)을 가지며, 내부 `Map<Integer, WeatherCondition>`을 통해 상수 조회
- weather_code → WeatherCondition 매핑: `BY_CODE` (불변 Map)
- `WeatherCondition.descriptionOf(int code)`를 통해 문자열 반환

### 3. 외부 API 연동 (OpenMeteoClient)
Spring WebFlux의 WebClient 를 사용하여 Open-Meteo API 호출.
> e.g. https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&current_weather=true
- 조회 필드:
- temperature_2m
- apparent_temperature
- relative_humidity_2m
- weather_code

### 4. WeatherService (핵심 비즈니스 로직)
- 담당 기능:
- 도시명 검증
- 위도/경도 기반 날씨 조회
- 하늘 상태(weather_code → 한글 설명) 변환
- 최종 사용자 응답 DTO 생성
- 자연스러운 한 줄 요약 문장 생성
> 예시: “현재 서울의 기온은 3.4℃이고, 체감 온도는 1.2℃입니다. 하늘 상태는 흐림이며, 습도는 65%입니다.”

### 5. 도시별 날씨 조회 API
`GET /api/weather?city={cityName}`
- 응답(JSON)
```
{
"city": "Seoul",
"temperature": 3.4,
"feelsLike": 1.2,
"condition": "부분적으로 흐림",
"humidity": 65,
"summary": "현재 서울의 기온은 3.4℃이고, 체감 온도는 1.2℃입니다. 하늘 상태는 부분적으로 흐림이며, 습도는 65%입니다."
}
```
---
## ⚠️ 에러 처리(예외 전략)
중앙 집중 방식(GlobalExceptionHandler) 적용.

| 상황 | HTTP Status | Error code | message |
|---------------------------|---------------------------|-----------------------|----------------------------|
| 입력 파라미터 누락/빈 값 | 400 Bad Request | INVALID_REQUEST | city 파라미터는 필수입니다. |
| 지원하지 않는 도시명 | 400 Bad Request | CITY_NOT_SUPPORTED | 지원하지 않는 도시입니다. |
| Open-Meteo API 호출 실패/타임아웃 | 502 Bad Gateway | EXTERNAL_API_ERROR | 외부 날씨 API 호출 중 오류가 발생했습니다. |
| 그 외 예상치 못한 서버 내부 오류 | 500 Internal Server Error | INTERNAL_SERVER_ERROR | 알 수 없는 오류가 발생했습니다. |

- JSON Sample
```
{
"code": "CITY_NOT_SUPPORTED",
"message": "지원하지 않는 도시입니다.",
"timestamp": "2025-01-01T12:00:00"
}
```
---
## 🧪 테스트 전략
테스트는 총 3단계로 구성됨.

### 1단계 — 단위 테스트 (Domain)
- 대상: City.from()
- 검증 내용:
- 정상 도시 매핑
- 대소문자 무시
- null / 빈 문자열 처리
- 미지원 도시 처리
- 파일: `CityTest.java`

### 2단계 — 단위 테스트 (Service)
- 대상: WeatherService
- FakeOpenMeteoClient 를 사용하여 외부 API 의존성 제거
- 검증내용:
- 요약 문장 생성 로직 검증
- 예외 처리 검증
- 파일: `WeatherServiceTest.java`

### 3단계 — Controller 테스트 (API Endpoint)
- 도구: MockMvc + @WebMvcTest
- 검증 내용:
- 정상 요청 시 200 OK + WeatherResponse 반환
- 잘못된 city 입력 시 400 INVALID_REQUEST
- 미지원 도시 요청 시 400 CITY_NOT_SUPPORTED
- 외부 API 에러 시 502 EXTERNAL_API_ERROR
- 파일: `WeatherControllerTest.java`
---
## 🤖 AI 활용 방식
- 기능 및 프로그래밍 요구사항을 AI 도구에 제공한 뒤, 소스 코드·테스트 코드·README.md 초안 생성을 요청함
- 생성된 코드를 검토하며 요구사항과 맞지 않는 부분을 지적하고, 필요한 리팩토링을 재요청함
- AI가 생성한 README 초안을 바탕으로 전체 문서 구조를 재정리하고, Markdown 표현 방식(bullet → table 등)을 개선함
- 생성된 테스트 코드를 직접 실행하여 통과 여부를 확인하고, 실패한 테스트에 대해 AI에게 디버깅을 요청함
- 코드/테스트/README 생성에 사용된 AI의 접근 방식과 설명을 참고하며, 이해가 필요한 부분은 별도로 질문함
- 리팩토링 및 디버깅 과정에서도 AI가 제시한 설명을 기반으로 추가 질문을 하며 내용을 보완함
- 본 섹션(AI 활용 방식, Lessons Learned)의 문장 표현 역시 AI에게 자연스럽게 다듬어 달라고 요청함

## 📘 Lessons Learned
- AI 도구를 활용하면 요구사항을 빠르고 효율적으로 구현할 수 있음을 체감함
- 그럼에도 결과물의 품질을 완전히 보장하려면, 사람의 검토와 판단이 여전히 필요한 부분도 있음
- AI가 생성한 산출물을 다시 AI에게 검증·보완시키는 자동화된 품질 관리 파이프라인을 구축하면 생산성이 더욱 높아질 것이라 판단함
- 여러 AI 도구가 서로 보완적으로 협력하는 파이프라인을 구축한다면, 결과물의 품질을 더욱 높일 수 있을 것이라 기대함
- AI는 인간의 역량을 확장시키는 도구이며, 이를 통해 더 많은 문제를 해결할 수 있다는 가능성을 확인함
16 changes: 15 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
plugins {
id 'java'
id("org.springframework.boot") version "3.5.8"
id("io.spring.dependency-management") version "1.1.7"
}

group = 'camp.nextstep.edu'
version = '1.0-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(21)
}
}

Expand All @@ -16,6 +18,18 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web:3.5.8'
implementation 'org.springframework.boot:spring-boot-starter-webflux:3.5.8'

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

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'

testImplementation 'org.springframework.boot:spring-boot-starter-test:3.5.8'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.assertj:assertj-core:3.25.3'
}
Expand Down
6 changes: 3 additions & 3 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0'
}
//plugins {
// id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0'
//}
rootProject.name = 'java-baseball'
13 changes: 13 additions & 0 deletions src/main/java/com/llm_precourse/weather/WeatherApplication.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.llm_precourse.weather;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class WeatherApplication {

public static void main(String[] args) {
SpringApplication.run(WeatherApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.llm_precourse.weather.common;

import com.llm_precourse.weather.dto.ErrorResponse;
import com.llm_precourse.weather.exception.ExternalApiException;
import com.llm_precourse.weather.exception.InvalidRequestException;
import com.llm_precourse.weather.exception.UnsupportedCityException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(InvalidRequestException.class)
public ResponseEntity<ErrorResponse> handleInvalidRequest(InvalidRequestException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.builder()
.code("INVALID_REQUEST")
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}

@ExceptionHandler(UnsupportedCityException.class)
public ResponseEntity<ErrorResponse> handleUnsupportedCity(UnsupportedCityException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.builder()
.code("CITY_NOT_SUPPORTED")
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}

@ExceptionHandler(ExternalApiException.class)
public ResponseEntity<ErrorResponse> handleExternalApi(ExternalApiException ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.builder()
.code("EXTERNAL_API_ERROR")
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(body);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex,
HttpServletRequest request) {
ErrorResponse body = ErrorResponse.builder()
.code("INTERNAL_SERVER_ERROR")
.message("알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
.timestamp(LocalDateTime.now())
.path(request.getRequestURI())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.llm_precourse.weather.common;

import com.llm_precourse.weather.dto.OpenMeteoResponse;
import com.llm_precourse.weather.exception.ExternalApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

@Component
@RequiredArgsConstructor
public class OpenMeteoClient {

private final WebClient webClient;

public OpenMeteoResponse getCurrentWeather(double latitude, double longitude) {
try {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/forecast")
.queryParam("latitude", latitude)
.queryParam("longitude", longitude)
.queryParam("current",
"temperature_2m,apparent_temperature,relative_humidity_2m,weather_code")
.build())
.retrieve()
.bodyToMono(OpenMeteoResponse.class)
.block();
} catch (Exception e) {
throw new ExternalApiException("외부 날씨 API 호출 중 오류가 발생했습니다.", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.llm_precourse.weather.common;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {

@Bean
public WebClient openMeteoWebClient() {
return WebClient.builder()
.baseUrl("https://api.open-meteo.com/v1")
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.llm_precourse.weather.controller;

import com.llm_precourse.weather.dto.WeatherResponse;
import com.llm_precourse.weather.service.WeatherService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class WeatherController {

private final WeatherService weatherService;

@GetMapping("/api/weather/v1")
public WeatherResponse getWeather(@RequestParam("city") String city) {
return weatherService.getWeather(city);
}

@GetMapping("/api/weather/v2")
public WeatherResponse getWeatherByLlm(@RequestParam("city") String city) {
return weatherService.getWeatherByLlm(city);
}
}
Loading