Loading...
Loading...
02-reusable-code-java/06-service/ExternalApiCacheService.java
/**
* 외부 API 호출 + Caffeine 캐싱 범용 패턴
* - RestTemplate 기반 GET/POST 호출
* - @Cacheable로 응답 캐싱 (Caffeine)
* - 헤더 커스터마이징 (X-Source-Context 등)
*
* @source 260313 car_reservation
* @extracted 2026-03-14
* @description RestTemplate + Caffeine 캐시 통합 서비스 패턴
*
* @dependencies
* - spring-boot-starter-web
* - spring-boot-starter-cache
* - com.github.ben-manes.caffeine:caffeine
* - lombok
*
* @config application.yml 설정 항목:
* app.external-api.base-url: http://localhost:8090 (외부 API 기본 URL)
*/
package com.example.app.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.Map;
/**
* 외부 API 호출 + 캐싱 서비스
*
* <h3>사용 예시</h3>
* <pre>
* // 1. CachingConfig에 캐시 이름 등록
* public static final String MY_CACHE = "myCache";
*
* // 2. 이 클래스를 상속하거나 참조하여 서비스 구현
* @Cacheable(cacheNames = "myCache", key = "#id")
* public MyDto getById(String id) {
* return getForObject("/api/v1/resource/" + id, MyDto.class);
* }
* </pre>
*
* <h3>캐싱 전략</h3>
* <ul>
* <li>GET 조회: @Cacheable 적용 (동일 키로 반복 호출 방지)</li>
* <li>POST 변경: 캐시 미적용 (항상 외부 API 호출)</li>
* <li>캐시 만료: CachingConfig의 expireAfterWrite 설정에 의존</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ExternalApiCacheService {
@Value("${app.external-api.base-url:http://localhost:8090}")
private String baseUrl;
@Value("${server.servlet.context-path:}")
private String serverContextPath;
private final RestTemplate restTemplate;
/**
* GET 요청 — 캐싱 가능
* - @Cacheable을 서브클래스 또는 호출부에서 적용
*
* @param path API 경로 (예: "/api/v1/users/123")
* @param responseType 응답 타입 클래스
* @return 응답 객체 (null일 수 있음)
*/
public <T> T getForObject(String path, Class<T> responseType) {
HttpHeaders headers = createHeaders();
HttpEntity<String> entity = new HttpEntity<>(headers);
try {
ResponseEntity<T> response = restTemplate.exchange(
new URI(baseUrl + path),
HttpMethod.GET,
entity,
responseType
);
if (response.getStatusCode().is2xxSuccessful()) {
return response.getBody();
} else {
log.warn("외부 API GET 실패 - path: {}, status: {}", path, response.getStatusCode());
return null;
}
} catch (Exception e) {
log.warn("외부 API GET 오류 - path: {}, error: {}", path, e.getMessage());
return null;
}
}
/**
* POST 요청 — 변경 작업이므로 캐싱 미적용
*
* @param path API 경로
* @param requestBody 요청 본문 (Map 또는 DTO)
* @param responseType 응답 타입 클래스
* @return 응답 객체 (null일 수 있음)
*/
public <T> T postForObject(String path, Object requestBody, Class<T> responseType) {
HttpHeaders headers = createHeaders();
HttpEntity<Object> entity = new HttpEntity<>(requestBody, headers);
try {
T response = restTemplate.postForObject(
baseUrl + path,
entity,
responseType
);
return response;
} catch (Exception e) {
log.warn("외부 API POST 오류 - path: {}, error: {}", path, e.getMessage());
return null;
}
}
/**
* POST 요청 (Map 본문)
*
* @param path API 경로
* @param bodyParams 요청 본문 파라미터
* @param responseType 응답 타입 클래스
* @return 응답 객체
*/
public <T> T postWithMap(String path, Map<String, String> bodyParams, Class<T> responseType) {
return postForObject(path, bodyParams, responseType);
}
/**
* 공통 HTTP 헤더 생성
* - Content-Type: application/json
* - X-Source-Context: 서버 컨텍스트 경로 (요청 출처 식별)
*
* @return 설정된 HttpHeaders
*/
protected HttpHeaders createHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Source-Context", getSourceContext());
return headers;
}
/**
* 소스 컨텍스트 반환 (요청 출처 식별용)
*/
private String getSourceContext() {
return serverContextPath.isEmpty() ? "/" : serverContextPath;
}
}