[Spring Boot] WebClient + Resilience4j로 외부 API 연동 — 캐싱/회로차단까지 한 방에 (Open-Meteo 예시)

Nolrimbo·2025년 8월 17일

포트폴리오

목록 보기
4/21

외부 API(날씨)를 WebClient로 호출하고, Resilience4j로 회로차단/재시도/속도제한, Caffeine Cache로 캐싱까지 한 번에 정리한 최소 완성(MVP) 템플릿.


✅ 구현 범위 (DONE)

  • GET /api/weather?lat=37.5665&lon=126.9780
    서울(Asia/Seoul) 기준 시간별 기온(°C) + 타임스탬프를 반환
  • WebClient(비동기) + Resilience4j(회로차단/재시도/속도제한/타임리미터) + Caffeine Cache(10분 TTL)
  • Swagger UI: /swagger-ui/index.html
  • 설정은 application.properties 기준

🧰 Tech Stack & Versions

  • Spring Boot 3.5.x
  • WebFlux (spring-boot-starter-webflux)
  • Resilience4j (resilience4j-spring-boot3, resilience4j-reactor)
  • Caffeine Cache
  • springdoc-openapi (WebFlux UI)

🗂️ 프로젝트 구조(요약)

src
├─ main
│  ├─ java/com/example
│  │  ├─ config
│  │  │  └─ WebClientConfig.java
│  │  └─ weather
│  │     ├─ controller
│  │     │  └─ WeatherController.java
│  │     ├─ dto
│  │     │  └─ WeatherResponse.java
│  │     └─ service
│  │        └─ WeatherService.java
│  └─ resources
│     └─ application.properties
└─ test ...

⚙️ Gradle 의존성

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분 (상황에 맞게 조정)

🔑 핵심 코드 하이라이트

1) WebClient 설정

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();
    }
}

2) DTO

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"
}

3) Service (Resilience4j + Cache)

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());
    }
}

4) Controller

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"
    }

✅ 동작 포인트 정리

  • WebClient: 비동기/논블로킹 호출
  • Resilience4j
    • @CircuitBreaker : 실패율이 임계치 넘으면 열림(Open) → 일정 대기 후 Half-Open
    • @Retry : 간헐적 네트워크 오류 등에 대해 최대 maxAttempts만큼 재시도
    • @RateLimiter : 초당 호출 수 제한 → 외부 API 보호
    • @TimeLimiter : 지연이 길면 타임아웃
  • Caffeine Cache
    • 동일 lat:lon 요청은 10분 동안 캐시 히트
    • TTL/사이즈는 운영 상황에 맞게 튜닝

🧯 Troubleshooting

  • Cannot resolve symbol 'reactive'
    spring-boot-starter-webflux 의존성 추가 후 Gradle Reload / ./gradlew clean compileJava
  • Swagger UI 404
    WebFlux 프로젝트면 springdoc-openapi-starter-**webflux**-ui 사용 (webmvc-ui 아님)
  • IDE 캐시 꼬임
    Gradle 탭에서 “Reload All Gradle Projects” / 인덱스 재빌드

📝 마무리

외부 API 연동을 실서비스처럼 구성하려면 회로차단/재시도/속도제한/타임리밋/캐싱은 사실상 필수 안전장치입니다.
이 템플릿은 날씨 예시(Open-Meteo)이지만, 번역·결제·지도 등 어떤 REST API에도 동일 패턴으로 바로 적용할 수 있어요.


✅ 참고

profile
Java 개발자 | 사이드 프로젝트 마니아 | GPT 기반 자동화 툴 연구 중 기술 리뷰, 개발일지

0개의 댓글