외부 API(날씨)를 WebClient로 호출하고, Resilience4j로 회로차단/재시도/속도제한, Caffeine Cache로 캐싱까지 한 번에 정리한 최소 완성(MVP) 템플릿.
GET /api/weather?lat=37.5665&lon=126.9780/swagger-ui/index.htmlapplication.properties 기준spring-boot-starter-webflux)resilience4j-spring-boot3, resilience4j-reactor)src
├─ main
│ ├─ java/com/example
│ │ ├─ config
│ │ │ └─ WebClientConfig.java
│ │ └─ weather
│ │ ├─ controller
│ │ │ └─ WeatherController.java
│ │ ├─ dto
│ │ │ └─ WeatherResponse.java
│ │ └─ service
│ │ └─ WeatherService.java
│ └─ resources
│ └─ application.properties
└─ test ...
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.4'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
description = 'weather-api'
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
repositories { mavenCentral() }
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
implementation 'io.github.resilience4j:resilience4j-reactor:2.2.0'
// ✅ WebFlux 전용 Swagger UI
implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.8.9'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
}
tasks.named('test') { useJUnitPlatform() }
application.properties)server.port=8080
# === 외부 API (Open-Meteo 예시) ===
api.weather.base-url=https://api.open-meteo.com/v1
api.weather.api-key=NO_KEY_REQUIRED_FOR_OPEN_METEO
# === Cache ===
spring.cache.type=caffeine
spring.cache.cache-names=weatherCache
spring.cache.caffeine.spec=maximumSize=200,expireAfterWrite=10m
# === Resilience4j (weatherApi) ===
resilience4j.circuitbreaker.instances.weatherApi.registerHealthIndicator=true
resilience4j.circuitbreaker.instances.weatherApi.slidingWindowType=COUNT_BASED
resilience4j.circuitbreaker.instances.weatherApi.slidingWindowSize=10
resilience4j.circuitbreaker.instances.weatherApi.failureRateThreshold=50
resilience4j.circuitbreaker.instances.weatherApi.waitDurationInOpenState=30s
resilience4j.circuitbreaker.instances.weatherApi.permittedNumberOfCallsInHalfOpenState=3
resilience4j.retry.instances.weatherApi.maxAttempts=3
resilience4j.retry.instances.weatherApi.waitDuration=300ms
resilience4j.ratelimiter.instances.weatherApi.limitForPeriod=10
resilience4j.ratelimiter.instances.weatherApi.limitRefreshPeriod=1s
resilience4j.timelimiter.instances.weatherApi.timeoutDuration=2s
포인트
timezone=Asia/Seoul로 로컬 시간대 정렬- 캐시 TTL 전역 10분 (상황에 맞게 조정)
package com.example.config;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.*;
@Configuration
@RequiredArgsConstructor
public class WebClientConfig {
@Value("${api.weather.base-url}")
private String weatherBaseUrl;
private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(req ->
reactor.core.publisher.Mono.fromRunnable(() ->
System.out.println("[WebClient] " + req.method() + " " + req.url())
).thenReturn(req)
);
}
private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(res ->
reactor.core.publisher.Mono.fromRunnable(() ->
System.out.println("[WebClient] status=" + res.statusCode())
).thenReturn(res)
);
}
@Bean
public WebClient weatherWebClient(WebClient.Builder builder) {
return builder
.baseUrl(weatherBaseUrl)
.filter(logRequest())
.filter(logResponse())
.defaultHeader("Accept", "application/json")
.build();
}
}
package com.example.weather.dto;
import lombok.*;
import java.util.List;
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class WeatherResponse {
private double latitude;
private double longitude;
private String timezone;
private List<String> hourlyTimes; // "2025-08-17T10:00"
private List<Double> hourlyTemperatures; // 29.6
private String temperatureUnit; // "°C"
}
package com.example.weather.service;
import com.example.weather.dto.WeatherResponse;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class WeatherService {
private final WebClient weatherWebClient;
@Cacheable(cacheNames = "weatherCache", key = "#lat + ':' + #lon")
@CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackWeather")
@Retry(name = "weatherApi")
@RateLimiter(name = "weatherApi")
@TimeLimiter(name = "weatherApi")
public Mono<WeatherResponse> getWeather(double lat, double lon) {
return weatherWebClient.get()
.uri(uri -> uri.path("/forecast")
.queryParam("latitude", lat)
.queryParam("longitude", lon)
.queryParam("hourly", "temperature_2m")
.queryParam("timezone", "Asia/Seoul") // ✅ 로컬 시간대
.build())
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(2))
.map(map -> {
Map<String, Object> hourly = (Map<String, Object>) map.get("hourly");
Map<String, Object> units = (Map<String, Object>) map.get("hourly_units");
List<String> times = (List<String>) hourly.get("time");
List<Double> temps = (List<Double>) hourly.get("temperature_2m");
String unit = (String) units.get("temperature_2m");
return WeatherResponse.builder()
.latitude(((Number) map.get("latitude")).doubleValue())
.longitude(((Number) map.get("longitude")).doubleValue())
.timezone((String) map.get("timezone"))
.hourlyTimes(times)
.hourlyTemperatures(temps)
.temperatureUnit(unit)
.build();
});
}
// 실패 시 대체 응답
private Mono<WeatherResponse> fallbackWeather(double lat, double lon, Throwable t) {
return Mono.just(WeatherResponse.builder()
.latitude(lat)
.longitude(lon)
.timezone("UNKNOWN")
.hourlyTimes(List.of())
.hourlyTemperatures(List.of())
.temperatureUnit("°C")
.build());
}
}
package com.example.weather.controller;
import com.example.weather.dto.WeatherResponse;
import com.example.weather.service.WeatherService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/weather")
@RequiredArgsConstructor
public class WeatherController {
private final WeatherService weatherService;
// 예: GET /api/weather?lat=37.5665&lon=126.9780
@GetMapping
public Mono<WeatherResponse> get(@RequestParam double lat,
@RequestParam double lon) {
return weatherService.getWeather(lat, lon);
}
}
./gradlew clean bootRun
Swagger UI: http://localhost:8080/swagger-ui/index.html
API 호출 예시
curl "http://localhost:8080/api/weather?lat=37.5665&lon=126.9780"
응답 예시(요약)
{
"latitude": 37.55,
"longitude": 127.0,
"timezone": "Asia/Seoul",
"hourlyTimes": ["2025-08-17T00:00", "..."],
"hourlyTemperatures": [23.9, 23.6, 23.5, ...],
"temperatureUnit": "°C"
}
@CircuitBreaker : 실패율이 임계치 넘으면 열림(Open) → 일정 대기 후 Half-Open@Retry : 간헐적 네트워크 오류 등에 대해 최대 maxAttempts만큼 재시도@RateLimiter : 초당 호출 수 제한 → 외부 API 보호@TimeLimiter : 지연이 길면 타임아웃lat:lon 요청은 10분 동안 캐시 히트Cannot resolve symbol 'reactive' →spring-boot-starter-webflux 의존성 추가 후 Gradle Reload / ./gradlew clean compileJavaspringdoc-openapi-starter-**webflux**-ui 사용 (webmvc-ui 아님)외부 API 연동을 실서비스처럼 구성하려면 회로차단/재시도/속도제한/타임리밋/캐싱은 사실상 필수 안전장치입니다.
이 템플릿은 날씨 예시(Open-Meteo)이지만, 번역·결제·지도 등 어떤 REST API에도 동일 패턴으로 바로 적용할 수 있어요.